Skip to content

fix: suppress [SILENT]-prefixed messages from reaching chat channels#1005

Closed
gorillahub wants to merge 49 commits intoqwibitai:mainfrom
gorillahub:fix/silent-message-suppression
Closed

fix: suppress [SILENT]-prefixed messages from reaching chat channels#1005
gorillahub wants to merge 49 commits intoqwibitai:mainfrom
gorillahub:fix/silent-message-suppression

Conversation

@gorillahub
Copy link
Copy Markdown

Summary

  • [SILENT] messages were leaking to Google Chat — Holly's internal action messages (prefixed with [SILENT]) were appearing in user-facing threads because no suppression logic existed anywhere in NanoClaw
  • Adds [SILENT] detection to formatOutbound() in router.ts — the single outbound formatting function
  • Unifies all three outbound paths to use formatOutbound():
    1. User-message streaming callback (index.ts:329) — was using inline regex, now uses formatOutbound()
    2. Scheduler path (index.ts:670) — already used formatOutbound(), now gets [SILENT] handling for free
    3. IPC send_message path (index.ts:675) — had no filtering at all, now filters through formatOutbound()
  • 4 new test cases covering [SILENT] suppression (432 total tests passing)

Behaviour

  • Messages starting with [SILENT] are suppressed (not sent to any channel)
  • [SILENT] in the middle of text is NOT suppressed (only prefix-match)
  • <internal> tag stripping happens first, then [SILENT] check — so <internal>reason</internal>[SILENT] action is also suppressed
  • The IPC path (MCP send_message tool) now has outbound filtering for the first time

Testing

vitest run — 432 tests passing
tsc --noEmit — clean

Craig Harffey and others added 30 commits March 7, 2026 17:17
…iner model

- Replace GroupState.active boolean with containers Map<string, ContainerSlot>
- Each container slot tracks independent lifecycle (idleWaiting, process, groupFolder)
- enqueueMessageCheck reuses idle containers before spawning, allows concurrent spawns
- sendMessage finds any idle non-task container in the group
- registerProcess/notifyIdle/closeStdin accept optional containerId parameter
- processMessagesFn callback signature now includes containerId
- pendingRegistrations map bridges containerId from runForGroup to registerProcess
- Global activeCount cap enforced across all groups' container slots
- drainGroup only starts new containers when under global cap
…istency

- Add inline comments for all 6 edge cases from plan verification
- Edge case 1: race condition — slot created synchronously, idleWaiting=false
- Edge case 2: idle reuse checked before global cap (no new slot needed)
- Edge case 3: finally blocks prevent orphan slots on error
- Edge case 4: multiple idle containers — first picked, others time out
- Edge case 5: task dedup scans all container slots, not single field
- Edge case 6: drainGroup only starts work when under cap, no interference
- Add 01-01-SUMMARY.md with full execution results
- Update STATE.md: plan 01-01 complete, advance to plan 02
- Replace 13 single-container tests with 17 multi-container tests
- Add CONC-01: concurrent containers for same group
- Add CONC-04: global cap across same-group containers
- Add CONC-05: idle container reuse (no new spawn)
- Add COMPAT-01: single message backward compatibility
- Add multi-group mixed concurrency under global cap
- Update all existing test mocks for containerId parameter
- 371/371 tests pass (was 366/367 before refactor)
- 17/17 tests pass covering CONC-01, CONC-04, CONC-05, COMPAT-01
- 371/371 full suite passes (zero regressions)
…upport

- processGroupMessages accepts containerId, threads to closeStdin/notifyIdle/registerProcess
- runAgent passes undefined sessionId for fresh sessions per container (CONC-02)
- Idle timeout targets specific container via containerId (CONC-03)
- SchedulerDependencies.onProcess includes containerId parameter
- Task fn callback receives containerId from GroupQueue.runTask
- QueuedTask.fn signature updated to (containerId: string) => Promise<void>
- startSchedulerLoop call in main() updated for new interface
- SUMMARY.md documents containerId threading, CONC-02/CONC-03 satisfaction
- STATE.md updated: Phase 01 complete (3/3 plans), progress 50%
- writeActiveSessionsFile/removeActiveSession/readActiveSessionsFile exports
- Atomic writes via temp file + rename pattern
- 11 tests covering write, append, remove, corrupt file, missing file, idempotency
- onContainerStart/onContainerExit callbacks on GroupQueue (optional, null by default)
- registerProcess fires onContainerStart when groupFolder is known
- Both runForGroup and runTask finally blocks fire onContainerExit
- index.ts wires callbacks to writeActiveSessionsFile/removeActiveSession
- Existing 371 GroupQueue tests unaffected (callbacks are optional)
- 02-01-SUMMARY.md with self-check passed
- STATE.md updated: Phase 2 in progress, 4/6 plans complete, 382 tests
…ntainer

- Add readSessionAwareness() to container agent-runner reading /workspace/ipc/active_sessions.json
- Format active sessions as <active-sessions> XML block prepended to initial prompt
- Filter out own container from the list using containerId
- Pass containerId through ContainerInput from host to container
- Graceful handling of missing, corrupt, or empty awareness files
- Add google-chat.ts channel adapter (service account auth, space
  resolution, message splitting)
- Register google-chat import in channels/index.ts barrel file
- Add @googleapis/chat and google-auth-library dependencies
- Format session-awareness files (lint fixes only)
IPC tasks (Google Chat messages, GMS triggers, GitHub push notifications)
were waiting up to 60s for the scheduler poll loop. Now the IPC watcher
calls triggerSchedulerCheck() after createTask(), so tasks are enqueued
within 1-2 seconds of arrival.
…sages

When two messages arrive within 1-2 seconds, the second task may not be
due yet when the first trigger fires. A 2-second follow-up check catches
these near-future tasks without waiting for the 60s poll cycle.
Tasks with context_mode 'group' were sharing the group's session ID.
When two tasks ran concurrently, the second blocked on the Claude API
session lock, causing 60s+ response times. All task containers now get
fresh sessions — they still have full group context via mounts.
Keep task containers alive after completing work (TASK_IDLE_TIMEOUT,
default 10 min) so subsequent tasks can reuse them via IPC instead of
cold-starting a new container (~3-5s vs ~20s).

- notifyIdle marks task containers warm with timeout + MAX_WARM_PER_GROUP
- enqueueTask checks for idle containers before spawning new ones
- onTaskReuse callback pipes task prompt to warm container via IPC
- task-scheduler uses TASK_IDLE_TIMEOUT instead of 10s close delay
- Best-effort SQLite bookkeeping for reused tasks
- 5 new tests (WARM-01 to WARM-05), 387 total passing
The mount security module blocks paths containing 'credentials' by default.
Moving to service-accounts/ allows the key to be mounted into containers
so Holly can access Google Calendar.
NanoClaw side of Google Chat threading support. Reads the most recent
thread name per JID from google-chat-threads.json (written by
trigger-writer on each inbound message) and passes it as
requestBody.thread when sending replies. Falls back to a new message
if the file is absent or the JID has no entry.

Requires companion change to trigger-writer.mjs to write the thread
name on each inbound webhook event.
Craig Harffey and others added 19 commits March 12, 2026 08:41
…ix context loss

Google Chat messages arrived as standalone IPC tasks, each spawning a
fresh container with zero conversation history. Craig would ask Holly
a question, get a reply, then follow up — and Holly had no idea what
he was referring to.

Three-part fix:
- trigger-writer: pass senderName/senderEmail/messageText as structured
  fields in the task JSON (VPS change already deployed)
- ipc.ts: store inbound gchat-msg-* messages in the messages DB, fetch
  the last 20 messages, and prepend as <conversation-history> XML block
- task-scheduler.ts: store Holly's outbound responses in the messages DB
  so subsequent messages include full bidirectional context

Also adds getRecentMessages() to db.ts — returns both user and bot
messages in chronological order, unlike getMessagesSince() which
filters out bot messages for the WhatsApp accumulation path.

12 new tests (416 total): 6 for getRecentMessages, 6 for conversation
history injection covering storage, prepending, edge cases.
The storeMessage call was hitting a FOREIGN KEY constraint because
gchat:pm-agent didn't exist in the chats table on the production DB.
Add storeChatMetadata call before storeMessage to create the chat
entry if it doesn't already exist.
…c pollution

When multiple Google Chat threads are active simultaneously, Holly's
conversation history was mixing all threads into a single context window.
This caused her to confuse topics — e.g. responding to a task management
question with M365 migration context.

Changes:
- Add thread_id column to messages table (auto-migrated)
- Add thread_id column to scheduled_tasks table (auto-migrated)
- Add getRecentMessagesByThread() for thread-filtered history queries
- Store thread_id on inbound Google Chat messages (from trigger-writer)
- Store thread_id on outbound bot messages (task-scheduler)
- Use thread-scoped history in gchat-msg prompt enrichment
- Falls back to all-messages when threadId is null (backward compat)
- 11 new tests covering thread isolation, fallback, limits, edge cases
- All 428 tests pass
feat(google-chat): thread-scoped conversation history
When a warm container is reused for a new task, the streaming callback
from the original runTask still runs with the old task in its closure.
This caused outbound messages to be tagged with the wrong thread_id.

Fix: track current task per container in a Map. The warm-reuse callback
(setContainerCurrentTask) updates it, and the streaming callback reads
from it instead of the closed-over task variable.
fix(google-chat): propagate thread_id through warm container reuse
…sk re-execution

Bug 1: sendMessage only accepted (jid, text) — Google Chat channel resolved
the thread from a file-based map that only stored the LAST thread per JID.
All replies went to whichever thread received the most recent message.
Fix: thread threadId through Channel.sendMessage → GoogleChatChannel →
SchedulerDependencies → task-scheduler streaming callback, using the task's
stored thread_id for reply routing.

Bug 2: one-shot tasks (gchat-msg-*) could be re-dispatched by a subsequent
scheduler check while the first container was still running. After the
container finished and went warm, the dedup in enqueueTask no longer saw
the task as 'running', so it was dispatched again — producing duplicate
responses. Fix: null out next_run immediately when a once task is picked
up by checkDueTasks, before enqueuing.
fix(google-chat): route replies to correct thread and prevent duplicate responses
Multiple warm containers for the same group share a single IPC input
directory. When two messages arrive close together, both warm containers
race to read the files — the wrong container can pick up the wrong
question, causing replies to appear in the wrong thread with wrong content.

Capping to 1 warm container per group eliminates the race. Messages
process sequentially (still fast via warm reuse, no cold start penalty).
Parallel warm containers can be restored once per-container IPC input
directories are implemented.
fix(google-chat): cap warm containers to 1 to prevent IPC race
…containers

When multiple tasks are piped to a warm container in quick succession,
setContainerCurrentTask overwrote the map entry immediately — before
the previous task's output was fully consumed. The streaming callback
then read the WRONG task's thread_id, sending the reply to the wrong
Google Chat thread.

Fix: replace the single-entry containerCurrentTask Map with a FIFO
queue (containerTaskQueue). Tasks are pushed on warm-reuse and shifted
off on completion (status: 'success'). The streaming callback always
reads the HEAD of the queue — the task currently producing output —
regardless of how many new tasks have been queued behind it.
fix(google-chat): task queue for warm container reuse prevents thread_id swap
…prompts

Warm containers retain Claude's session memory from previous tasks.
When a message from thread B is piped into a container that just
answered thread A, Claude sees both conversations and may merge them
into a single response — answering both questions together.

Wraps gchat task prompts with a <new-thread-message> directive telling
Claude to treat the piped message as independent and ignore prior
session context from other threads.
…lation

fix(google-chat): thread isolation directive for warm-reused containers
Messages prefixed with [SILENT] are used by agents for internal actions
(e.g. logging to Airtable) that should not be visible to users.

- Add [SILENT] detection to formatOutbound() in router.ts
- Unify user-message streaming callback to use formatOutbound() instead
  of inline <internal> stripping (index.ts:329)
- Add formatOutbound() filtering to IPC send_message path which
  previously had no outbound filtering at all (index.ts:675)
- Add 4 test cases for [SILENT] suppression behaviour
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