Implements 3 coalescing layers to handle rapid multi-attachment inbounds: - Bus debouncer: delays inbound messages 1s, merges duplicates - Web chat debouncer: buffers client-side inbound frames for batch RPC - Telegram album aggregator: collects album members via AfterFunc+Stop timer Drops media-bypass shortcut (forces 1s media floor). Aggregator enforces: - AfterFunc+Stop timer discipline with ordered drain on stop - 2-tuple (album_id, sender) keying for isolation - Dual DoS caps: max 10 albums per sender, max 100 messages per album - merged_message_ids dedup seeding across all 3 surfaces Closes #63
8.3 KiB
Contributing to GoClaw
Branch Strategy
main (stable, protected — owner-only merge)
└── dev (default target for all PRs)
├── feat/xxx
├── fix/xxx
└── ...
Rules
- All PRs target
dev—mainis frozen for stable releases - Hotfixes — PR to
main, then cherry-pick todev - Releases — owner merges
dev→mainwhen stable - Direct push to
main— blocked (ruleset enforced)
Branch Naming
feat/description— new featuresfix/description— bug fixeshotfix/description— urgent production fixes (targetmain)refactor/description— code improvementsdocs/description— documentation changes
PR Guidelines
Before Submitting
go fix ./... # Apply Go upgrades
go build ./... # PG build check
go build -tags sqliteonly ./... # Desktop build check
go vet ./... # Static analysis
go test -race ./... # Tests with race detector
For web UI changes:
cd ui/web && pnpm build
PR Review Criteria
Based on our automated review checklist:
- Correctness: No logic errors, nil dereference, race conditions
- Security: Parameterized SQL, no hardcoded secrets, input validation
- Breaking changes: API contracts, DB migrations, config format
- Tenant isolation: All queries scoped by
tenant_id. Admin writes require the correct scope guard — see section below - i18n: User-facing strings in all 3 locales (en/vi/zh)
- SQLite parity: Changes compile with
-tags sqliteonly - Mobile UI:
h-dvhnoth-screen, 16px input fonts, safe areas
Tenant-Scope Guards
RoleAdmin checks role, not tenant. A non-master tenant admin holds RoleAdmin in their own tenant and passes role-only middleware. Pick the guard by the target table:
| Target | Example | Guard |
|---|---|---|
Global (no tenant_id column) |
builtin_tools, disk config, pip/npm/apk |
HTTP requireMasterScope · WS requireMasterScope(requireOwner(...)) |
Tenant-scoped (has tenant_id column) |
agents, skills, llm_providers |
requireTenantAdmin + store SQL WHERE tenant_id = $N |
Shared predicate: store.IsMasterScope(ctx) (internal/store/context.go).
Anti-patterns flagged in review:
store.Update(...)on a no-tenant_idtable without a master-scope check upstream- Write SQL with
WHERE ... (tenant_id = $N OR tenant_id IS NULL)— theIS NULLarm lets tenants reach system rows requireAuth(RoleAdmin)as the sole gate on a global-state write- Admin revoke/delete handlers that skip pre-fetch ownership verification (store SQL alone is not enough when it matches
IS NULLarms)
Multi-attachment coalescing (#63)
Three independent surfaces coalesce burst inbounds so one user action produces one agent run. Any future surface that fans burst arrivals into the agent loop MUST honor these eight invariants. Drift on any of them re-introduces the N-replies bug.
- No media bypass. A message carrying attachments goes through the same silence window as text. The pre-fix "publish immediately when media is present" shortcut is the original #63 regression — do not reintroduce it.
- Media floor. When attachments are present, the effective window is
max(configured, mediaFloor). Configured can be 0 (disabled) for text-only flows; once media arrives the floor is the lower bound so multi-file uploads have time to land. - Per-key buffer. Buffer key is the smallest tuple that uniquely names
"this user action in this delivery channel" —
(channel, chatID, senderID, agentID)for the bus debouncer,(userKey, sessionKey)for web chat,(chatID, MediaGroupID)for Telegram albums. - Sender pin on first arrival. First arrival pins the senderID on the
buffer. Subsequent arrivals with a mismatched sender are dropped with a
security.*_sender_mismatchwarn log. Defense-in-depth against spoofed updates; the platform should never reuse a group/session id across senders. - Drop-and-log dual caps. Per-buffer cap AND global active-buffer cap.
Overflow logs
*.overflowwithscope=buffer|global, drops the straggler, and returns false to caller — caller falls through to single-message dispatch so no message is silently lost. - AfterFunc + Stop, never Reset. Use
time.AfterFunc(window, fn)andtimer.Stop()on every arrival.time.Timer.Reset()has a documented double-fire race when the timer is mid-fire — banned. - Representative is members[0]. The first arrival's resolved context (sender label, content prefix, reply target, topic config) is the one that flows downstream on flush. Later arrivals contribute their media only.
- Synchronous Stop drain. Shutdown order is
aggregator.Stop()→pollCancel()→handlerWg.Wait(). Stop synchronously flushes all pending buffers BEFORE any context is cancelled so in-flight bursts reach the agent loop. Post-Stop pushes are rejected with a warn log.
Surfaces today: internal/bus/inbound_debounce.go,
internal/gateway/methods/chat_debounce.go,
internal/channels/telegram/album_aggregator.go.
Test Layers
Tests are organized by priority and purpose:
| Layer | Priority | Location | Blocking? | Purpose |
|---|---|---|---|---|
| Invariants | P0 | tests/invariants/ |
YES | Tenant isolation, permission enforcement |
| Contracts | P1 | tests/contracts/ |
YES | API schema validation |
| Scenarios | P2 | tests/scenarios/ |
NO | End-to-end user journeys |
| Integration | P1 | tests/integration/ |
YES | DB/pipeline integration |
Running Tests
make test # Unit tests (fast, no DB)
make test-invariants # P0 invariants (requires pgvector)
make test-contracts # P1 API contracts (requires server)
make test-scenarios # P2 scenarios (requires server)
make test-critical # P0 + P1 (run before merge)
Test Layer Policy
- P0 failures: Block PR merge immediately
- P1 failures: Block merge, investigate contract breakage
- P2 failures: Warning only, may indicate flaky tests or environment issues
When adding new tests:
- Tenant isolation/permissions →
tests/invariants/ - API response schemas →
tests/contracts/ - User journeys (multi-step flows) →
tests/scenarios/
Commit Messages
Use conventional commits:
feat: add user preferences API
fix: prevent race condition in session cleanup
docs: update API reference for v2 endpoints
refactor: extract provider retry logic
Workflow
Developer Reviewer Owner
│ │ │
├─ create feat/xxx ──────────┤ │
├─ PR → dev ─────────────────┤ │
│ ├─ review + approve │
│ ├─ CI passes ──────────┤
│ │ ├─ merge to dev
│ │ │
│ │ (when stable) ├─ PR dev → main
│ │ ├─ merge → auto release
│ │ │ (semantic-release)
Releases
Standard (automatic)
Merge dev → main. go-semantic-release analyzes commit messages and auto-creates:
- GitHub Release with version tag (
vX.Y.Z) - Cross-platform binaries (linux/darwin × amd64/arm64)
- Docker images (4 variants: latest, base, full, otel + web)
- SHA256 checksums
- Discord notification
Beta (manual tag)
Push a beta tag from dev to create a prerelease:
# Standard beta — builds Docker + Linux binaries
git tag v2.67.0-beta.1
git push origin v2.67.0-beta.1
# Desktop beta — builds macOS .dmg + Windows .exe
git tag lite-v1.2.0-beta.1
git push origin lite-v1.2.0-beta.1
Beta releases are marked as prerelease on GitHub and use :beta rolling Docker tag.
Desktop / Lite
Push a lite-v* tag to build desktop apps:
git tag lite-v1.1.0
git push origin lite-v1.1.0
Tags with -beta or -rc suffix automatically create prereleases.