- Add a database-backed supervisor with durable scheduling, fenced leases,
cooldowns, bounded worker concurrency and expired-lease restart recovery.
- Default new goals to persistent execution with zero meaning unlimited
cumulative budget; preserve legacy goals and explicit positive limits.
- Yield bounded graph segments to the supervisor instead of ending unfinished
goals at graph-local continuation limits. Require persisted checklist
evidence before accepting completion, including concurrent criterion edits.
- Share conversation admission across interactive and background execution;
preserve partial replies, usage and queued user input during interruption.
- Persist Stop and missing-input pauses, respect approval boundaries, and
commit resume and approval-denial transitions with correct transactions.
- Retry identifiable transient failures with backoff; retain visible pauses
for budget limits and errors that require review instead of replaying tools.
- Expose owner-authorized execution status and reconnectable scheduling events;
add H2, MySQL and Kingbase migrations, API types and bilingual documentation.
Validation: 298 focused backend tests passed, including persistence, restart
scheduling, approval races, cancellation, admission and existing runtime tests.
Frontend type checking, bundled-doc parity and ID precision checks passed.
V188 is registered and all three dialects have unique migration versions;
the migration-map audit still reports 95 pre-existing missing registrations.
Scope: single-backend native runtime. Recovery checks existing state before
repeating effects; this does not promise exactly-once external tool execution.
Stopping a conversation previously disposed the outer reactive stream without reliably cancelling synchronous tool callbacks running on worker threads. Shell commands, skill scripts, and Playwright sessions could therefore outlive the visible chat turn.
Propagate cancellation through run-scoped hooks, interrupt active tool execution and parallel batches, terminate subprocess trees, close per-conversation browser sessions, and wait briefly for final stream persistence before acknowledging Stop.
Expose an explicit interrupting state in the chat input to block duplicate stop clicks and show progress, with regression coverage for cancelling an in-flight synchronous tool callback.
- router.onError fallback: a failed route-chunk load hard-navigates to the
clicked route once (guarded against reload loops) instead of hanging
silently until a manual refresh
- warm all lazy route chunks during idle time after login, so sidebar
navigation no longer depends on live chunk fetches under load
- SSE executor switches to a virtual-thread-per-task executor, matching
the app-wide virtual-thread model
After the workspace-aware chat-uploads change, the upload root became
absolute (the resolver normalizes via toAbsolutePath/normalize, and the
autoconfiguration rewrites baseDir to an absolute path). ChatController.upload
then set ChatUploadResponse.path to that absolute path — despite the inline
comment promising a relative path "to avoid exposing the server's absolute
path". The field is rendered into the LLM prompt ("附件: foo (path)") and
returned to the client, so this leaked the server filesystem layout into both
the prompt and the response, and broke portability if the deploy dir moves.
Extract toRelativeUploadPath(uploadRoot, convId, storedName) which makes the
path relative to the upload root's parent (preserving the trailing sub-dir
name, e.g. chat-uploads/{convId}/{storedName}) and normalizes separators to
'/'. Retrieval is unaffected: it goes through the basename-based
ChatUploadResolver and the /api/v1/chat/files/... URL, not this field.
Adds ChatControllerUploadPathTest (default root, absolute workspace-scoped
root, custom base-dir name) asserting the result is relative and leak-free.
Addresses the blocker item in #452.
The identity forwarded to opt-in MCP servers was a one-dimensional string
(ChatOrigin.requesterId): a MateClaw username for web logins, but a webchat
visitorId for visitors and an IM sender id for IM — indistinguishable to the
REST backend. The signed-token mode (d204b702) made this worse: an RS256
signature over an unauthenticated visitorId reads as "MateClaw authenticated
this user" to any backend that trusts the signature.
Introduce an identity-typing dimension at McpIdentityForwardService:
- classify() branches on ChatOrigin: authenticated (web login, sub=immutable
userId), anonymous (webchat visitor, trust=anonymous), external (IM sender,
trust=external), or none (cron/system → nothing injected, fail-closed).
- mint() adds `trust` and `channel_type` claims; plaintext value is prefixed
`trust:subject` so backends can tell the kinds apart without a JWT.
The immutable userId reaches resolve() without coupling it to the user store:
JwtAuthFilter stamps user.id into auth.setDetails() (both JWT and PAT paths),
and ChatController.memoryOrigin carries it on a new ChatOrigin.requesterUserId
field (only-add, per the record's evolution rule).
Resolves the webchat semantic mismatch raised in #459 and the "sub should be
an immutable user id" follow-up. 82 tests green (4 identity classes covered
with claim assertions + full ChatOrigin/MCP regression).
(cherry picked from commit b5d2cfbf98b39848d7139c743a0b81fea71e8ffe)
Follow-up to #447. The generated-file link extraction accepted any
non-')' text before the path, so a paren-free javascript:/data: URL
embedding /api/v1/files/generated/<id> could be captured and bound to an
<a href>, enabling XSS on click. Adopt the scheme-restricted pattern
already used by SegmentSupersedeDetector and the channel adapters, on
both backend (ChatController) and frontend (useChat). Also replace the
inline fully-qualified Pattern/Matcher with imports and drop an unused
run-overview i18n key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract generated-file download links from tool results — on the backend (persisted to message metadata for history) and on the frontend (live during SSE) — de-duplicate by URL, and render them as a Generated Files section with file-type icons and a rail badge.
Streaming chat ran render tools on an async thread with no bound request,
so download links lost their host and arrived without a domain. Resolve the
host on the request thread and carry it through ChatOrigin/ToolContext;
falls back to a configurable public-base-url, then a relative path.
Replaces the prior ThreadLocal context plumbing with explicit Spring AI
ToolContext threading carried by an immutable ChatOrigin value object,
so a cron created from inside WeChat (or any IM channel) delivers its
results back to the originating channel.
Architecture
- ChatOrigin / ChannelTarget value objects + per-entry-point factories
(ChannelChatOriginFactory in vip.mate.channel, CronChatOriginFactory
in vip.mate.cron — symmetric, no cyclic deps).
- LocaleAwareToolCallback now forwards call(String, ToolContext) and
getToolMetadata so the decorator chain cannot silently drop the origin.
- AgentService 6-method overhaul + ChatOriginHolder bridge into
StateGraph buildInitialState which writes CHAT_ORIGIN; ActionNode +
StepExecutionNode forward it to ToolExecutionExecutor.
- ToolExecutionExecutor builds ToolContext per call; 8/8 tools migrated
(CronJobTool, WorkspacePathGuard, Video/Image/Browser/ReadFile/Music,
DelegateAgentTool with parent-origin inheritance).
- CronJobRunner + CronJobLifecycleService 3-segment REQUIRES_NEW model
(T1 startRun / no-tx runAgent / T2 finishRunAndPublish); ArchUnit
pins CronJobRunner as @Transactional-free.
- CronResultDelivery Strategy + AbstractCronResultDelivery Template
with SQL CAS idempotency on mate_cron_job_run.delivery_status —
replaces the prior process-local Caffeine TTL, cluster-safe.
- CronJobCompletedEvent + @Async @TransactionalEventListener(AFTER_COMMIT);
cronDeliveryExecutor (core=2, max=4, queue=1000, AbortPolicy + audit).
- CronRunStaleCleanup @Scheduled(5min) sweeps PENDING-15min and
status='running'-30min in one query each.
- CronJobRunner.wrapWithDeliveryGuard prepends a system note for
channel-bound crons to suppress hallucinated 'install CLI to send
WeChat' suggestions.
- ApprovalWorkflowService Memento: persist ChatOrigin snapshot on
create, restore on replay so cross-restart approvals keep channel
binding; ChannelMessageRouter + ChatController web-replay both prefer
the Memento and fall back to fresh-build.
- ChannelManager.sendToChannel 4-arg DeliveryOptions overload;
ChannelAdapter#proactiveSend default 4-arg pass-through; Slack
overrides for thread_ts and Telegram overrides for message_thread_id.
- CronJobs UI: read-only 'last delivery' badge driven by
CronJobMapper.selectListWithDeliveryStatus subquery.
Schema migrations V57/V58/V59 (V56 was already taken by an unrelated
provider migration — Flyway processes versions in order regardless of
gaps):
- V57: mate_cron_job_run delivery_status / target / error + composite
index (delivery_status, started_at) covering the cleanup sweep.
- V58: mate_cron_job channel_id (indexed) + delivery_config TEXT (JSON
via MyBatis Plus JacksonTypeHandler).
- V59: mate_tool_approval chat_origin TEXT (Memento).
All idempotent in both H2 (IF NOT EXISTS) and MySQL (INFORMATION_SCHEMA
guard + PREPARE).
ArchUnit guards (test scope, archunit-junit5 1.3.0):
- every concrete vip.mate.* ToolCallback must override
call(String, ToolContext) — pins the decorator-forward fix.
- CronJobRunner must NOT carry @Transactional on the class or any
method — pins the 3-segment lifecycle rule.
Tests: 32 new unit tests + 21 regression tests in touched areas, all
53 green:
- ChatOriginTest (6) — value-object invariants + JSON round-trip.
- LocaleAwareToolCallbackToolContextTest (2) — decorator forward.
- DeliveryConfigTest (4) — Jackson round-trip + forward-compat.
- ToolCallbackToolContextForwardArchTest (2) — both ArchUnit guards.
- CronJobRunnerDeliveryGuardTest (3) — channel-cron prefix injection.
- AbstractCronResultDeliveryTest (4) — claim CAS + concurrent CAS.
- ChannelCronResultDeliveryTest (6) — supports / doDeliver / errors.
- ApprovalReplayContinuityTest (5) — Memento round-trip + corrupt
payload fallback + unknown-field tolerance.
Refs: #25, #16
- Reconcile approval status atomically: DB row, message metadata, in-memory store
- Approve and deny both flip the tool-call card + timeline segment to a terminal
state on the gate message — no more orange spinner stuck after a decision
- Frontend hydrate matches by pendingId and reverse-converges to expired so a
refresh after server-side timeout / consume clears the banner without restart
- Stop sweep, GC timeout, and JVM restart all close the loop with consistent
state
- Remove the dead REST /approve endpoint + matching frontend client export so
there is only one resolve path to maintain
Foundation for the ghost-approval root-cause fix.
Adds ResolveOutcome / MetadataDecision; rewrites ApprovalWorkflowService so
every resolve / consume / timeout / supersede transitions through one
two-phase contract: snapshot → DB UPDATE conditional on status=PENDING →
metadata reconciliation → afterCommit memory mutation. ChatController,
ChannelMessageRouter, and ApprovalController all switch to the workflow;
ApprovalService.resolve / resolveAndConsume / consumeApproved /
cancelStalePending / denyAllByConversation are physically removed so
DB-bypass is no longer reachable at compile time.
Specific fixes:
- recoverFromDb preserves DB pendingId + createdAt (was generating fresh
random ids, breaking every later DB sync)
- effectiveExpireAt = expireAt ?? createdAt + PENDING_TTL: legacy rows
with NULL expireAt no longer resurrect as live PENDING after restart
- markPendingApprovalsResolved flips pendingApproval.status + currentPhase
+ MessageEntity.status atomically (was only flipping the first field;
message.status uses existing completed/stopped, not approved/denied,
to stay within the frontend Message.status union)
- GC scheduler moves to ApprovalWorkflowService; timeouts and overflow
evictions now sync DB + metadata + memory through markTimeout
- DB UPDATE rows=0 returns alreadyResolved (concurrent-resolve safe);
exception propagates so @Transactional rolls back; memory stays untouched
- expireRecoveredRow gates metadata write on DB success (was writing
metadata even when DB update failed, producing the worst-case ghost)
- Mockito JDK 21 agent attach fixed via maven-dependency-plugin properties
+ surefire argLine (no more flaky self-attach across machines)
Tests: 34 new across 4 classes (recovery, resolve, GC, metadata sync).
Full suite: 788 / 788.
The two tools-sync scripts ran on every startup and used H2 MERGE INTO
... KEY(id), which overwrites every column on existing rows. That
silently reverted UI-toggled `enabled` and was the proximate cause of
a recent WriteFileTool/EditFileTool outage.
They were also a strict subset of the fresh-install seed (data-zh.sql /
data-en.sql register all 19 builtins; the sync scripts only 16) and out
of date. Per-tool Flyway migrations (V3, V31) are already the canonical
'register a new builtin' path, so the sync layer was duplicated and
error-prone.
Delete both files and the runToolSyncScript() loader. Tool descriptions
shown to the LLM come from @Tool annotations in code, not the DB row,
so removing per-startup metadata refresh has no functional impact.
A bundle of stability fixes that all surfaced together while running
the same long-form generation task across multiple turns. Each one
addresses a distinct way the previous behavior silently dropped
content the user had already seen on screen.
1. Mid-turn narrative persistence (StateGraphReActAgent +
SummarizingNode). Intermediate ReasoningNode rounds and
SummarizingNode broadcast their content_delta directly to the
SSE channel for live display, but the StreamAccumulator only
received the final answer. After refresh the assistant message
showed only tool_call cards with no body text.
StateGraphReActAgent now also forwards STREAMED_CONTENT (already
set per round) as a persistOnly StreamDelta whenever it changes,
so every narrative chunk lands in the accumulator's content
buffer and gets written to mate_message. SummarizingNode now
writes its summary into the same key so summarize narratives
persist too.
2. Follow-up message queue, not dispose (ChatController#interruptStream).
Sending a new message while a turn was running called
requestInterrupt, which dispose()d the active Reactor chain mid
LLM call. That cancelled the in-flight generation, lost partial
tokens, and left the user staring at a half-finished bubble.
The endpoint now uses enqueueMessage in all paths, matching
the "wait for current turn, then run" behavior. The old
requestInterrupt API is kept for any future force-replace UI
but no caller routes to it.
3. Queued user message ordering (ChatStreamTracker.QueuedInput +
ChatController.startQueuedMessage). interruptStream used to save
the queued user message immediately, before the in-flight
assistant message finalized in doOnError. listMessages orders
by create_time ASC, so the queued user message ended up above
the assistant reply it was supposed to follow. QueuedInput now
carries contentParts; persistence is delayed to startQueuedMessage,
which runs only after Asst-N is on disk.
4. JVM shutdown flush (ChatStreamTracker @PreDestroy +
emergencySaveAccumulator). A mvn spring-boot:run restart used to
wipe in-flight turns: SSE emitter timed out, ShutdownHook fired,
HikariPool closed before doOnError could save. ChatStreamTracker
now exposes an emergency-save callback per RunState; ChatController
registers one per stream that snapshots the accumulator and
writes status="interrupted_shutdown". @PreDestroy walks active
runs, invokes the callback, then disposes. Spring's reverse-order
bean teardown keeps ConversationService and Hikari alive long
enough for the save to complete.
5. Observation thresholds for summarize (GraphObservationProperties +
application.yml). The previous total-chars threshold of 12 KB
triggered summarize after one or two RFC reads, costing a 40 to
80 second compaction LLM call per loop. Tuned to: total 200 KB,
single 16 KB, large-result 32 KB, rounds safety net 25. Java
field defaults reverted to the conservative original values so
application.yml stays the source of truth.
6. Frontend thinking segmentation (useChat.ts thinking_delta +
phase). Multi-round ReAct turns merged every reasoning + summarize
round's thinking into one segment, accumulating to 9 KB+ in a
single bubble. thinking_delta now uses findLast(running) so a
tool_call_started or phase transition closes the previous segment
and the next delta opens a fresh one. phase event also closes
running thinking/content segments.
7. Other small things bundled: removed a debug metadata-keys log
that flooded the log file with one line per stream chunk; fixed
three stale tests that didn't compile after earlier constructor
changes (WikiLogServiceTest, WikiOverviewSpliceTest,
WikiProcessingServiceLazyTest); added rfc-066 documenting the
unified message queue + priority refactor as the next logical
step on top of these stabilizations.
Verified end-to-end with multiple full sessions: a four-minute
generation that produced the expected docx and a follow-up enqueue
that ran cleanly after the previous turn naturally completed,
without the old "Disposable unavailable" interrupt path.
Same bug as the prior queue-drop fix in doOnComplete, but in the
sister branch that fires when the agent's reactive stream errors
out (CancellationException from a user stop). The guard
cr.queuedInput() != null && !(isUserStop && !isInterruptFollowup)
mis-classified "user stopped, no interrupt-with-followup, but a
message is in the queue" as an explicit abort and silently dropped
the freshly-typed follow-up.
The frontend's enqueue path never sets interruptType — it just
calls requestStop + offers to messageQueue. Whoever puts a message
in the queue means it; just run it. Aligns with doOnComplete and
the four other queue-launch sites in this controller.
A series of cross-cutting stability fixes that surfaced together
during a long debugging session.
reasoning_content / Claude prefill self-replicating 400:
- ChatController persists typed errors (content starts with '[错误] ')
with status='error', so the failure text stops being re-sent as
multi-turn context — DeepSeek thinking 400 ('reasoning_content
must be passed back') and Claude 400 ('does not support assistant
message prefill') used to recursively re-create themselves every
retry by polluting history.
- BaseAgent.sanitizeForLlm filters status='error' / '[错误] ' prefix
assistant messages from history before LLM dispatch.
- BaseAgent.fetchHistoryMessages defensively drops trailing
AssistantMessages — Claude rejects assistant-tail prompts.
- NodeStreamingChatHelper.dropTrailingAssistant runs the same
defense at every doStreamCall pre-egress, so the in-turn
summarizing→reasoning transition (which leaves an assistant
scaffold at the tail) doesn't trip Claude either.
- AgentGraphBuilder.FallbackPolicy.DEEPSEEK switched (null,true,true)
→ (' ',false,true), aligning with KIMI/OPENAI's tolerant ' '
fallback. The previous 'force explicit 400' design was the
self-replicating loop's prime mover.
narration + tool args truncation:
- ReasoningNode.DEFAULT_MAX_OUTPUT_TOKENS 4096 → 16384. The 4k cap
was decapitating renderDocx tool_call args mid-stream when the
model emitted a long content field on top of thinking content;
the resulting 'invalid JSON' aborted execution silently.
- ReasoningNode appends a hermes-style TOOL_USE_ENFORCEMENT clause
to every system prompt: 'when you say you will perform an action,
call the tool now in the same response — narration is a protocol
violation'. Treats 'now I will generate the docx' (and never
actually calling renderDocx) as a forbidden pattern.
- ToolExecutionExecutor.normalizeToolExecutionError reframes the
JSON-truncated error as actionable instructions: 're-call the
same tool now with shorter content or split into multiple
sequential calls; do NOT describe the result as text'.
side fixes from the same evening:
- ChatController doOnComplete skips completionPublisher.publish
when isError=true, keeping memory extraction off the garbage path.
- ChatController doOnComplete queued-message guard simplified to
'cr.queuedInput() != null', matching the other 4 sites in the
controller. The previous 'isInterruptFollowup || !wasStopped'
guard silently dropped queued messages when the user did
Stop-then-Enqueue (wasStopped=true && interruptType=null), losing
the freshly-typed follow-up message.
- prompts/graph/summarize-system.txt now distinguishes 'single
task' (default; output one cohesive summary) from 'multiple
independent sub-tasks' (use the子任务 N format). Stops the
summarizer from inventing '子任务 1: PRO-027' decomposition for
unitary requests like 'write me a project proposal'.