Files
multica/server/internal/daemon/execenv/context.go
Bohan Jiang 2d9c153695 feat: quick-create issue (async agent + inbox completion) (#1786)
* feat(server): add quick-create issue async task path

Adds POST /api/issues/quick-create which validates the picked agent's
reachability up front (not archived, has runtime, runtime online) then
queues an issue-less agent task whose context JSONB carries the user's
natural-language prompt + requester + workspace. Daemon claim resolves
the workspace from the context, and the prompt builder switches to a
quick-create template instructing the agent to translate the prompt
into a single multica issue create call.

Task completion writes a success inbox item to the requester pointing at
the newly-created issue (located by querying the agent's most recent
issue in the workspace since task start, so we don't depend on agent
stdout shape). Failures write an action_required inbox item carrying the
original prompt + agent id so the frontend can offer "Edit as advanced
form" without losing input.

* feat(views): quick-create issue modal + inbox failure CTA

Adds a streamlined create-issue UI bound to the c shortcut: pick an
agent, type one line, submit. The modal closes immediately and the
agent translates the prompt into a multica issue create call in the
background. Shift+c keeps the legacy advanced form for users who want
every field. The "Advanced" button inside the new modal seeds the
shared issue-draft store with the prompt + picked agent so switching
mid-flow doesn't lose input.

Last-used agent persists per (user, workspace) via a workspace-aware
zustand store so frequent users skip the picker on every open.

Inbox renders quick_create_done items with a status pin to the new
issue and quick_create_failed items with an "Edit as advanced form"
CTA that re-seeds the legacy modal with the original prompt.

ApiError now carries the parsed JSON body so the modal can branch on
the structured agent_unavailable code without parsing the error
message.

* fix(quick-create): execenv injection, claim race, private-agent permission

Addresses GPT-Boy review on #1786:

1. execenv was rendering the assignment-task issue_context.md / runtime
   workflow even for quick-create, telling the agent to call
   `multica issue get/status/comment add` against an empty IssueID.
   Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create
   branch in renderIssueContext + the runtime_config workflow that
   instructs the agent to run a single `multica issue create` and
   exit, with explicit "do NOT call issue get/status/comment add"
   guards.

2. ClaimAgentTask serialized only on issue_id / chat_session_id, so
   concurrent quick-creates on the same agent (both NULL on those
   columns) ran in parallel — making the success-inbox lookup race
   over "most recent issue by this agent". Adds a third OR clause
   that treats "all four FKs NULL" as a serialization key for the
   same agent, so quick-create tasks on a given agent run one at a
   time.

3. QuickCreateIssue handler bypassed the private-agent ownership rule
   that validateAssigneePair enforces elsewhere — a user could POST a
   private agent_id they didn't own and trigger it. Now routes the
   picked agent through validateAssigneePair before the runtime
   liveness check.

4. Clarifies the quick-create-store namespacing comment to match the
   actual workspace-aware StateStorage convention used by the other
   issue stores (per-user is browser-profile-local).

* fix(quick-create): branch Output section + deterministic origin lookup

Addresses GPT-Boy's second-pass review on #1786:

1. The runtime_config.go Output section forced "Final results MUST be
   delivered via multica issue comment add" for every non-autopilot
   task — quick-create still got this conflicting instruction even
   though there's no issue to comment on. Switched the Output block
   to a three-way switch so quick-create gets a tailored "stdout is
   captured automatically; do NOT call comment add" branch matching
   the autopilot variant.

2. Completion lookup was "most recent issue created by this agent
   since task.started_at", which races against concurrent issue
   creates by the same agent (assignment task running alongside
   quick-create when max_concurrent_tasks > 1). Replaced with a
   deterministic origin link:

   - Migration 060 extends issue.origin_type CHECK to allow
     'quick_create'.
   - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a
     quick-create task.
   - multica issue create CLI reads the env var and stamps the new
     issue with origin_type=quick_create + origin_id=<task_id>.
   - Server CreateIssue handler accepts (origin_type, origin_id)
     from trusted callers (only "quick_create" is allowed; the pair
     is rejected unless both fields are provided together).
   - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on
     (workspace_id, "quick_create", task.ID) — no more time-window
     racing against parallel agent activity.

The old GetRecentIssueByCreatorSince query is removed.
2026-04-29 14:05:26 +08:00

243 lines
8.8 KiB
Go

package execenv
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// writeContextFiles renders and writes .agent_context/issue_context.md and
// skills into the appropriate provider-native location.
//
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
// Codex: skills → handled separately in Prepare via codex-home
// Copilot: skills → {workDir}/.github/skills/{name}/SKILL.md (native project-level discovery)
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
// Pi: skills → {workDir}/.pi/skills/{name}/SKILL.md (native discovery)
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
// Kiro: skills → {workDir}/.kiro/skills/{name}/SKILL.md (native discovery)
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
contextDir := filepath.Join(workDir, ".agent_context")
if err := os.MkdirAll(contextDir, 0o755); err != nil {
return fmt.Errorf("create .agent_context dir: %w", err)
}
content := renderIssueContext(provider, ctx)
path := filepath.Join(contextDir, "issue_context.md")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write issue_context.md: %w", err)
}
if len(ctx.AgentSkills) > 0 {
skillsDir, err := resolveSkillsDir(workDir, provider)
if err != nil {
return fmt.Errorf("resolve skills dir: %w", err)
}
// Codex skills are written to codex-home in Prepare; skip here.
if provider != "codex" {
if err := writeSkillFiles(skillsDir, ctx.AgentSkills); err != nil {
return fmt.Errorf("write skill files: %w", err)
}
}
}
return nil
}
// resolveSkillsDir returns the directory where skills should be written
// based on the agent provider.
func resolveSkillsDir(workDir, provider string) (string, error) {
var skillsDir string
switch provider {
case "claude":
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".claude", "skills")
case "copilot":
// GitHub Copilot CLI natively discovers project-level skills from
// .github/skills/<name>/SKILL.md (takes precedence over user-level
// skills in ~/.copilot/skills/).
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
skillsDir = filepath.Join(workDir, ".github", "skills")
case "opencode":
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
case "pi":
// Pi natively discovers skills from .pi/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".pi", "skills")
case "cursor":
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".cursor", "skills")
case "kimi":
// Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
// in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
skillsDir = filepath.Join(workDir, ".kimi", "skills")
case "kiro":
// Kiro CLI auto-discovers project-level skills from .kiro/skills/
// in the workdir.
skillsDir = filepath.Join(workDir, ".kiro", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
}
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return "", err
}
return skillsDir, nil
}
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
// sanitizeSkillName converts a skill name to a safe directory name.
func sanitizeSkillName(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = nonAlphaNum.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "skill"
}
return s
}
// writeSkillFiles writes skill directories into the given parent directory.
// Each skill gets its own subdirectory containing SKILL.md and supporting files.
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return fmt.Errorf("create skills dir: %w", err)
}
for _, skill := range skills {
dir := filepath.Join(skillsDir, sanitizeSkillName(skill.Name))
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
// Write main SKILL.md
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill.Content), 0o644); err != nil {
return err
}
// Write supporting files
for _, f := range skill.Files {
fpath := filepath.Join(dir, f.Path)
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
return err
}
if err := os.WriteFile(fpath, []byte(f.Content), 0o644); err != nil {
return err
}
}
}
return nil
}
// renderIssueContext builds the markdown content for issue_context.md.
func renderIssueContext(provider string, ctx TaskContextForEnv) string {
if ctx.AutopilotRunID != "" {
return renderAutopilotContext(ctx)
}
if ctx.QuickCreatePrompt != "" {
return renderQuickCreateContext(ctx)
}
var b strings.Builder
b.WriteString("# Task Assignment\n\n")
fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID)
if ctx.TriggerCommentID != "" {
b.WriteString("**Trigger:** Comment Reply\n")
b.WriteString("**Triggering comment ID:** `" + ctx.TriggerCommentID + "`\n\n")
} else {
b.WriteString("**Trigger:** New Assignment\n\n")
}
b.WriteString("## Quick Start\n\n")
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
// There is no issue yet, so we explicitly tell the agent NOT to call
// `multica issue get` / `status` / `comment add` — those would either error
// (empty IssueID) or silently target an unrelated issue.
func renderQuickCreateContext(ctx TaskContextForEnv) string {
var b strings.Builder
b.WriteString("# Quick Create\n\n")
b.WriteString("**Trigger:** Quick-create modal\n\n")
b.WriteString("There is NO existing Multica issue for this run. Translate the user input below into a single `multica issue create` invocation, then exit.\n\n")
b.WriteString("## User input\n\n")
b.WriteString("> ")
b.WriteString(ctx.QuickCreatePrompt)
b.WriteString("\n\n")
b.WriteString("## Rules\n\n")
b.WriteString("- Run exactly one `multica issue create` invocation. No retries.\n")
b.WriteString("- After it succeeds, print `Created MUL-<n>: <title>` and exit.\n")
b.WriteString("- Do NOT run `multica issue get`, `multica issue status`, or `multica issue comment add` — there is nothing to query, transition, or comment on.\n")
b.WriteString("- The platform writes the user's success/failure inbox notification automatically based on the CLI exit status.\n\n")
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available, but for quick-create they are usually unnecessary:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}
func renderAutopilotContext(ctx TaskContextForEnv) string {
var b strings.Builder
b.WriteString("# Autopilot Run\n\n")
fmt.Fprintf(&b, "**Autopilot run ID:** %s\n\n", ctx.AutopilotRunID)
if ctx.AutopilotID != "" {
fmt.Fprintf(&b, "**Autopilot ID:** %s\n\n", ctx.AutopilotID)
}
if ctx.AutopilotTitle != "" {
fmt.Fprintf(&b, "**Title:** %s\n\n", ctx.AutopilotTitle)
}
if ctx.AutopilotSource != "" {
fmt.Fprintf(&b, "**Trigger source:** %s\n\n", ctx.AutopilotSource)
}
if ctx.AutopilotTriggerPayload != "" {
fmt.Fprintf(&b, "## Trigger Payload\n\n```json\n%s\n```\n\n", ctx.AutopilotTriggerPayload)
}
b.WriteString("## Quick Start\n\n")
b.WriteString("This is a run-only autopilot task with no assigned issue. Do not run `multica issue get` unless the autopilot instructions explicitly ask you to create or update an issue.\n\n")
if ctx.AutopilotID != "" {
fmt.Fprintf(&b, "Run `multica autopilot get %s --output json` if you need the full autopilot configuration.\n\n", ctx.AutopilotID)
}
if strings.TrimSpace(ctx.AutopilotDescription) != "" {
b.WriteString("## Autopilot Instructions\n\n")
b.WriteString(ctx.AutopilotDescription)
b.WriteString("\n\n")
}
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}