mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* fix(daemon): keep the runtime brief byte-stable across triggers (MUL-5377) Claude Code loads the runtime brief (CLAUDE.md / AGENTS.md) into messages[0], ahead of the entire conversation. A cache breakpoint is all-or-nothing, so a single differing byte there invalidates the prompt cache for the whole history on every `--resume`. InjectRuntimeConfig rewrites that file on every run and interpolated nine per-run values into it, so in practice the cache was thrown away on the first comment that landed on any issue. Measured on one issue over three runs: run 1 (cold) spent 89.9k cache-write tokens building 105k of context; runs 2 and 3 each spent ~425k re-creating a prefix they should have read. 842k of 946.5k cache-write tokens (89%) went into re-creation, with only tools[]+system[] surviving each resume (a constant 18,085 tokens both times). Fix: the brief now carries only what is stable for the lifetime of a resumed session, and per-run state travels in the per-turn user message, which is appended after the cached prefix. - Merge kindCommentTriggered + kindAssignmentTriggered into kindIssue, and stop reading TriggerCommentID in classifyTask. The brief can no longer diverge by trigger type structurally, rather than by convention. - Replace writeWorkflowComment/writeWorkflowAssignment with one writeWorkflowIssue that routes on the per-turn message. The mode-specific status rules live inside their own mode block, so "own the status arc" and "do not touch the status" can never be read as unarbitrated peers. - Move Task Initiator, Session Continuity Notice and Connected Apps out of the brief into BuildPrompt via BuildTaskInitiatorBlock / SessionContinuityNotice / BuildConnectedAppsBlock. - Drop TriggerCommentID, TriggerThreadID, NewCommentsSince, NewCommentCount, PriorSessionResumed and CommentReplyTargets from the brief; BuildPrompt already emitted all six from the same helpers, so this is de-duplication. - Set PriorSessionResumeUnavailable on `task` as well as `taskCtx` in both local resume gates, or the notice would silently vanish on exactly the failure path it exists to disclose. Tests: TestInjectRuntimeConfigByteIdenticalAcrossTriggers renders the brief across nine per-run variants (trigger type, differing comment/thread ids, resume delta, resume-unavailable, cross-thread fan-out, member/agent initiator, connected apps) for two providers and requires bytes.Equal, with a non-vacuity guard so it cannot pass on a function that ignores its input. Daemon-side tests assert the moved sections still reach the agent through the per-turn prompt. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): route the issue workflow on an explicit turn-mode marker Review follow-up on the mode router. The brief said Reply mode applies when the per-turn message "opens with a [NEW COMMENT] block", but buildCommentPrompt writes two paragraphs before that block and only emits it when TriggerCommentContent is non-empty. Two ways to get it wrong: - The message never literally opens with the block, so the router's own wording did not match the prompt it describes. - A comment-triggered run with an empty comment body — or an older server that does not send one — emitted no block at all. An agent following the brief would fall through to Ownership mode and change the issue status on a turn whose rule is "do NOT change the issue status". BuildPrompt now emits an unconditional `**Turn mode: Reply.**` / `**Turn mode: Ownership.**` line from the same branches it uses to pick a code path, and the brief routes on that marker. Brief and prompt can no longer disagree about the mode, because the value that selects the path also states it. The router also names a safe fallback (treat an unlabelled turn as Reply mode and leave the status alone). Tests: TestTurnModeMarkerAlwaysPresent covers comment-triggered with and without comment content, plus both assignment shapes; TestTurnModeMarkerAbsentOnIssuelessKinds keeps the marker off chat / quick-create / autopilot; TestBriefModeRouterMatchesPromptMarkers fails if the brief ever describes a marker the prompt does not emit. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
74 lines
3.0 KiB
Go
74 lines
3.0 KiB
Go
package execenv
|
|
|
|
// taskKind labels the dispatch path that the runtime brief should
|
|
// follow for a given TaskContextForEnv. Used by
|
|
// `buildMetaSkillContentSlim` (MUL-3560 brief; the `runtime_brief_slim`
|
|
// flag that once gated it against a legacy verbose brief was retired in
|
|
// MUL-4297, so this is now the only brief).
|
|
//
|
|
// Four kinds, mutually exclusive in practice. classifyTask documents the
|
|
// tiebreak rule that applies if a future caller accidentally violates the
|
|
// mutex.
|
|
type taskKind int
|
|
|
|
const (
|
|
// kindIssue: this run operates on a real Multica issue. It deliberately
|
|
// does NOT distinguish comment-triggered from assignment-triggered runs.
|
|
//
|
|
// Those were two kinds until MUL-5377. Splitting them made the rendered
|
|
// brief — which Claude Code loads into messages[0], ahead of the entire
|
|
// conversation — differ between the first (on-assign) run and every
|
|
// later (comment) run on the same resumed session, which invalidated the
|
|
// prompt cache for the whole history on every resume. Which trigger
|
|
// fired THIS turn is per-turn state and now travels in the per-turn user
|
|
// message (daemon.BuildPrompt), which is appended after the cached
|
|
// prefix. See runtime_config_sections.go:writeWorkflowIssue.
|
|
kindIssue taskKind = iota
|
|
// kindAutopilotRunOnly: an autopilot fired in run-only mode (no
|
|
// issue created or attached).
|
|
kindAutopilotRunOnly
|
|
// kindQuickCreate: one-shot "create an issue from a natural-language
|
|
// prompt" task.
|
|
kindQuickCreate
|
|
// kindChat: interactive chat session, no issue.
|
|
kindChat
|
|
)
|
|
|
|
// classifyTask maps a TaskContextForEnv to the single taskKind the slim
|
|
// brief should be assembled for. Precedence (documented for the tiebreak
|
|
// case, although the daemon never sets two specific-kind flags at once):
|
|
// chat → quick-create → autopilot run-only → issue.
|
|
//
|
|
// Deliberately does not read ctx.TriggerCommentID: the classification must
|
|
// not vary across runs of the same resumed session, or the brief's bytes
|
|
// change and the prompt cache is lost from messages[0] onward (MUL-5377).
|
|
func classifyTask(ctx TaskContextForEnv) taskKind {
|
|
switch {
|
|
case ctx.ChatSessionID != "":
|
|
return kindChat
|
|
case ctx.QuickCreatePrompt != "":
|
|
return kindQuickCreate
|
|
case ctx.AutopilotRunID != "":
|
|
return kindAutopilotRunOnly
|
|
default:
|
|
return kindIssue
|
|
}
|
|
}
|
|
|
|
// hasIssueContext returns true for the kinds that operate on a real Multica
|
|
// issue and therefore can read / pin issue-scoped state. The slim
|
|
// dispatcher gates these two sections on this predicate:
|
|
//
|
|
// - Issue Metadata
|
|
// - Sub-issue Creation
|
|
//
|
|
// Both are meaningless on the issue-less kinds (chat / quick-create /
|
|
// autopilot run-only) and would either render an empty body or steer the
|
|
// agent into a guaranteed-failed CLI call. Note this is a kind-based
|
|
// predicate, not a check on ctx.IssueID — kindIssue always carries an issue
|
|
// id by construction (the daemon refuses to dispatch it otherwise), and the
|
|
// other three kinds never do.
|
|
func (k taskKind) hasIssueContext() bool {
|
|
return k == kindIssue
|
|
}
|