Skip to content

fix: quickstart systemd restart + topic assign platform detection#85

Open
changhansung wants to merge 304 commits into
suzuke:mainfrom
songsid:fix/quickstart-restart-and-topic-assign
Open

fix: quickstart systemd restart + topic assign platform detection#85
changhansung wants to merge 304 commits into
suzuke:mainfrom
songsid:fix/quickstart-restart-and-topic-assign

Conversation

@changhansung

Copy link
Copy Markdown
Contributor
  1. Quickstart prefers systemctl restart (keeps watchdog) over detached spawn
  2. assignTopicIds uses instance channel_id → channels[].type instead of name.includes()

suzuke and others added 30 commits April 23, 2026 21:55
Telegram bot API requests embed the bot token in the URL path
(`/bot<token>/...`). A misconfigured or malicious `telegram_api_root`
would silently leak the token to whatever host is configured.

Validate at startup: only api.telegram.org (https) and loopback
(localhost / 127.0.0.1 / ::1, for E2E mock servers) are accepted.
Anything else throws — fail fast at config load rather than after
the first API call sends credentials to the attacker.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The 409 conflict retry loop was unbounded — a permanently stuck
conflict would keep retrying forever every 15s. Cap at 30 attempts
(~7 min total) and emit a final error so the operator can intervene.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The previous 10 MB ceiling let a runaway producer (or hostile peer
sharing the socket) buffer 10 MB per client before being dropped.
Real IPC payloads — tool calls/responses, decision/task lists — are
well under 100 KB; 1 MB stays comfortably above any legitimate
message while shrinking the DoS window 10x.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The flood-control branch in MessageQueue.runWorker had a dead comment
("reset backoff now that we've cleaned up") but never actually reset the
backoff. As a result, once sustained 429s drove backoffMs above
FLOOD_CONTROL_THRESHOLD_MS, the queue would shed all status_update items
yet still wait the full ~30s exponential delay between every retry — even
though the queue was now small and the rate-limit may already have eased.

Fix:
- After dropping items, reset backoffMs to INITIAL_BACKOFF_MS and clear
  backoffUntil, so the next retry happens within ~1s instead of ~30s.
- Emit a warn log noting how many items were dropped, so operators can
  see flood control activity instead of silent drops.

Test: tests/channel/message-queue.test.ts asserts that after sustained
429s push backoff past the threshold, status_updates are mostly dropped,
the warn fires, and the surviving content message still gets delivered.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
When control mode disconnected and reconnected, paneToWindow and
lastOutputAt kept stale paneId → windowId mappings from the previous
tmux server. After a tmux restart pane ids are recycled, so
waitForIdle / isIdle could consult a pane that no longer belongs to
the expected window (or has been recycled to a different one) —
returning false "idle" and letting us paste into the wrong target.

Track every registered windowId in a Set; on (re)connect clear the
transient paneId caches and re-resolve panes for all registered
windows so upper layers don't need to know about the reconnect.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
setInterval keeps firing the poll callback even when the previous
invocation is still awaiting stat/open/read. With slow disk or a
large transcript two concurrent runs would race on byteOffset —
both could read the same byte range and emit duplicate tool_use /
assistant_text events to downstream consumers.

A simple boolean guard makes the second tick a no-op until the
first finishes; setInterval itself stays unchanged.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
If the daemon was down across a scheduled fire time, the schedule
would silently skip until the next regular trigger. Now `init()`
walks every enabled schedule and, if its most recent expected run
falls in the past 24h, fires it once.

The 24h cap avoids dumping a backlog of pings on the user after
a long outage, while still recovering from short crashes/restarts.
At most one catch-up per schedule — `* * * * *` doesn't replay 59
missed minutes.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
When a daily limit fires, the limit handler pauses the instance.
If the user manually restarts that instance, the new session causes
a cost-rotation event but `limitEmitted` stayed sticky for the rest
of the day — so the new session could blow past the daily cap again
without ever re-pausing. Same shape for the warn flag.

Reset both flags on `snapshotAndReset` so the next cost report
re-evaluates against the (still-elevated) accumulated total and
re-emits if the threshold is still crossed. Within a single session
each event still fires at most once.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Old impl used `setHours(24, 0, 0, 0)` (a local-tz operation) on a Date
constructed by reinterpreting the target-tz wall-clock as local-tz time.
The two reinterpretations cancel for normal days, but on DST days the
target tz's day length differs from local arithmetic by an hour:

- Spring-forward day in target tz (23h day): scheduler fires ~1h LATE.
- Fall-back day in target tz (25h day): scheduler fires ~1h EARLY.

Either way the daily reset misaligns with the user's calendar day twice
a year — exactly when daily-budget alerts most need to be predictable.

Replaced with an Intl.DateTimeFormat-based binary search that finds the
first instant where the date *as observed in the target tz* changes.
This naturally handles 23h, 24h, and 25h day lengths regardless of
local-vs-target tz mismatches.

Tests cover the two specific buggy moments (LA pre-jump 01:30 PST and
LA pre-fallback 01:30 PDT) plus a 26h upper bound and positivity invariant.
The archived Set lived in memory only — after a daemon restart the
poller would re-archive every previously-closed idle topic and
re-issue closeForumTopic, fighting users who had reopened topics
manually. Persist to <dataDir>/archived-topics.json on add/remove
and load on construct.

Adds dataDir to ArchiverContext (FleetManager already exposes it).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The 6-hex suffix on getTmuxSessionName/getTmuxSocketName is not
cryptographic — it just disambiguates two custom AGEND_HOME values on
the tmux namespace. md5 was fine functionally but trips FIPS-mode Node
and routine security scanners. Switching to sha256 (still slice(0,6))
removes those false positives.

Behaviour change: users with a custom AGEND_HOME will see their
tmux session/socket name change once on first run after the upgrade.
A daemon restart resyncs cleanly; any orphan tmux session under the
old name can be killed manually. Default AGEND_HOME (which uses the
literal string "agend" with no suffix) is unaffected.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- src/scheduler/scheduler.ts no longer defines its own validateTimezone;
  it imports the canonical implementation from src/config.ts. This was
  the only duplicate left after PR #34 — error messages now consistently
  read "timezone: invalid timezone \"X\". Use IANA format ...".
- src/quickstart.ts and src/setup-wizard.ts now write the .env file with
  mode 0o600 and chmod after the write (writeFileSync's mode flag is
  ignored for existing files). The .env contains the bot token and
  optionally a Groq API key — restricting to owner-only read/write
  prevents other local users / sibling processes from reading them.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
…ernally

`rm -rf ~/.agend/instances/<name>` while the daemon was running caused the
health check loop to spam ENOENT / tmux-died / respawn-failed errors every
~30s indefinitely (5000+ errors in ~20h observed in daemon.log).

Root cause: every tick the health check saw pane dead → respawn path →
writeRotationSnapshot(snapshotPath) → writeFileSync ENOENT → caught as
"Failed to respawn" → scheduleNext() → loop.

Fix: check existsSync(instanceDir) at the start of each tick; if missing,
pause the loop and clear the timer. TranscriptMonitor and ContextGuardian
already handle missing files silently, so no other cleanup is needed.
Previously writeFileSync used default perms (0o644), exposing the
fleet-control token to other users on the same host. Tightened to 0o600
on create and defensive chmod for any pre-existing looser file.

Addresses ultrareview Phase 1 item P1.2.
`startInstancesWithConcurrency` already awaits `startInstance` →
`lifecycle.start` → `daemon.start` (IPC listening) → `connectIpcToInstance`
for every instance. By the time it resolves, the IPC channel is already
wired, so the trailing `await sleep(3000)` + `connectToInstances(fleet)`
loop in `start()` and the `await sleep(5000)` + second
`connectIpcToInstance` in `bindAndStart()` were dead weight that just
slowed down startup and topic creation.

Removed both redundant waits and the now-unused private
`connectToInstances` helper.
Two leaks in the Web UI SSE pipeline:

1. /ui/events only listened for `req.on("close")`. A network reset
   that never delivers a clean FIN would leave the client in
   `sseClients` forever and the 10s heartbeat interval running until
   the daemon shut down.

2. `emitSseEvent` blindly called `client.write()`. The first dead
   client to throw aborted delivery to every later client in the set,
   and the dangling Set entry itself was never cleaned up.

Extract `broadcastSseEvent()` so the eviction logic is testable,
add `req.on("error")` and `res.on("error")` to the handler with an
idempotent cleanup, and route `FleetManager.emitSseEvent` through
the helper.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
When adding allowed guilds to classicBot.yaml, read the Discord bot
token from .env and list servers the bot is in. User can select from
the list instead of manually entering guild IDs. Falls back to manual
entry for unlisted servers.
…ld listing

readDiscordToken() was hardcoded to look for AGEND_DISCORD_TOKEN in
.env, but the actual env var name is configured in fleet.yaml's
bot_token_env (could be AGEND_BOT_TOKEN or anything else).

Also coerce allowed_guilds to strings — yaml may load bare numbers.
Classic bot instances from classicBot.yaml now appear in list_instances
results with tag 'classic' and display_name 'classic: <channel-name>'.
Allows agents to discover and interact with classic bot instances.
…lock

createTopic() used guild.channels.cache which is empty on fresh
environments, causing each channel to create its own category.

- Add promise-based lock (categoryIdPromise) so concurrent callers
  share one resolution instead of racing
- Fetch all guild channels on first call to find existing category
- Invalidate cache on Discord error 10003 (deleted category) and retry
- Clear rejected promise so transient API failures don't stick
Document attachments were only passed as file_id for manual download,
unlike photos which are auto-downloaded. Now documents are downloaded
to inbox with attachment_path in extraMeta and a [📎 File] prefix,
falling back to file_id on download failure.
Extend saveClassicAttachment() to download both photo and document
attachments to workspace inbox. Returns { path, kind } so the caller
can set the correct extraMeta (image_path vs attachment_path) and use
appropriate emoji reactions (📸 for photos, 📎 for documents).
- New classicBot creation: collect admin_users after backend
  (pre-filled with quickstart userId, can add more)
- Patch mode: renamed maybeAddClassicBotGuilds → maybeUpdateClassicBot,
  now also asks 'Add admin users? [y/N]' with show/collect/append
- Output format includes defaults.admin_users alongside allowed_guilds
… /raw

Part 1: ClassicChannelManager
- defaults.admin_users?: string[] in classicBot.yaml
- isAdmin(userId): empty/unset = no admins (secure default)

Part 2: Discord adapter
- Register /compact, /save (filename + force), /load (filename) slash commands
- Extract interaction options as key-value pairs in slash_command event

Part 3: Fleet manager
- Handle /compact, /save, /load with admin check
- pasteRawToClassicInstance: send raw_paste IPC to bypass [user:] wrapping
- /save supports force option (appends -f flag)

Part 4: Daemon
- raw_paste IPC handler: paste directly to CLI
- /raw prefix in pushChannelMessage: topic mode users can paste raw text
  (protected by allowed_users access control upstream)
…paste

1. Classic channel /chat /raw bypass: users could send '/chat /raw /compact'
   to bypass admin check. Now handleClassicChannelMessage blocks messages
   starting with /raw after stripping /chat prefix.

2. raw_paste IPC handler now uses pasteLock + deliverMessage instead of
   direct tmux.pasteText, preventing race conditions with concurrent
   message deliveries and respecting idle wait.
C1: /update authorization — empty allowed_users now disables /update
    (was: empty = anyone can update). Matches upstream/main behavior.

C2: /save /load filename injection — validate filename against [\w.-]+
    to prevent newline injection via tmux paste.

C3: Restore shellQuote for AGEND_HOME and AGEND_INSTANCE_NAME in
    daemon env prefix. JSON.stringify uses double quotes which allow
    $ and backtick interpretation in bash.
- daemon.ts: emit message_queued (when pasteQueueDepth > 0) and
  message_delivered (after deliverMessage) with chatId + messageId
- instance-lifecycle.ts: listen for both events, call ctx.reactMessageStatus
- fleet-manager.ts: reactMessageStatus reacts on the user's message,
  gated to Discord adapter only
changhansung and others added 30 commits June 15, 2026 14:05
feat: notify General when non-admin tries /start or /stop
docs: add kiro-cli session storage path to skill
fix: agend update auto-removes stale npm link
fix: systemd NotifyAccess=all + TimeoutStartSec=0
feat: /status + /sysinfo markdown tables (Rich Message compatible)
fix: install.sh removes npm-linked old version via readlink
fix: /sysinfo markdown table + kiro error pattern
fix: workspace path validation + classic group unbound topic suppression
ci: add contents:write permission for auto Release
docs: v2.0.0 + v2.0.1 CHANGELOG
feat: memory layering best practice in fleet steering
fix: memory best practice - add guards against hallucination
Previously only triggered when NO general existed at all.
Now detects each channel adapter without a bound general and
auto-creates one. Uses new channel_id field for explicit binding.
findGeneralInstance prefers channel_id match over name heuristic.
fix: auto-create general per adapter in multi-channel setup
docs: multi-channel skill (dual platform setup)
- Removed fleet.pid blocker for 'add users' and 'add platform' actions
- Add platform: detached spawn 'agend fleet restart --reload' (avoids SIGUSR1 exit(0) + Restart=on-failure bug)
- Add users: SIGHUP hot-reload
- Only 'Overwrite' still requires fleet to be stopped
feat: quickstart supports adding platform while fleet is running
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.

2 participants