mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
fix(cli,quick-create): no duplicate issue when --attachment fails post-create
Two coordinated fixes for a quick-create case where the agent ended up
creating duplicate issues. Repro: user pasted an image into the
quick-create prompt; the front-end uploaded it and embedded the URL as
markdown in the user input; the agent saw the URL, assumed it was an
attachment, and ran `multica issue create … --attachment "https://…"`.
The CLI POSTed the issue first, then failed to read the URL as a file
(`os.ReadFile("https://…")`) and exited 1. The agent treated exit 1 as
"create failed" and retried — but the first issue already existed, so
the workspace ended up with two of them.
CLI (`server/cmd/multica/cmd_issue.go`):
- `runIssueCreate` pre-validates `--attachment` BEFORE POSTing. URLs are
warned about and skipped (they are never local files); local-path
read errors fail before the issue is created so no half-baked issue
lands. Once the POST succeeds, post-create upload failures only
print a stderr warning and the issue metadata is still emitted —
never a non-zero exit, so callers cannot mistake "attachment upload
hiccup" for "create failed" and retry.
- `runIssueCommentAdd` already uploads attachments BEFORE the comment
is created, so its failure mode is fine; it just gets the same
URL-skip behaviour for consistency.
Quick-create prompt (`buildQuickCreatePrompt`):
- Tells the agent NOT to pass `--attachment` for prompt-embedded image
URLs (they are already part of the description as markdown).
- Hardens the "no retry" rule: even on a non-zero exit, do not retry
`issue create` — the issue may already exist.
This commit is contained in:
@@ -465,6 +465,15 @@ func runIssueGet(cmd *cobra.Command, args []string) error {
|
||||
return cli.PrintJSON(os.Stdout, issue)
|
||||
}
|
||||
|
||||
// isHTTPURL reports whether path is an http:// or https:// URL.
|
||||
// Used to skip URL-shaped values passed to --attachment, which only
|
||||
// accepts local file paths. Trims surrounding whitespace because
|
||||
// agent-generated commands sometimes copy URLs with stray spaces.
|
||||
func isHTTPURL(path string) bool {
|
||||
p := strings.TrimSpace(path)
|
||||
return strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://")
|
||||
}
|
||||
|
||||
func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||
title, _ := cmd.Flags().GetString("title")
|
||||
if title == "" {
|
||||
@@ -529,22 +538,53 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||
body["origin_id"] = taskID
|
||||
}
|
||||
|
||||
// Pre-validate attachments BEFORE creating the issue so a bad path
|
||||
// can never produce a half-created issue (which would otherwise
|
||||
// trigger callers — especially the agent doing quick-create — to
|
||||
// retry the whole `issue create` and end up with duplicates).
|
||||
//
|
||||
// - http(s) URLs are not local files; the API only accepts local
|
||||
// paths here. Warn and skip rather than fail — a markdown image
|
||||
// URL embedded in the prompt should never be re-attached, and
|
||||
// skipping is the safest outcome for that case.
|
||||
// - Anything else is treated as a local path and read upfront.
|
||||
// A read failure here is a real user/agent mistake (typo,
|
||||
// missing file) and we surface it pre-create so the issue
|
||||
// never lands.
|
||||
type pendingAttachment struct {
|
||||
path string
|
||||
data []byte
|
||||
}
|
||||
pending := make([]pendingAttachment, 0, len(attachments))
|
||||
for _, filePath := range attachments {
|
||||
if isHTTPURL(filePath) {
|
||||
fmt.Fprintf(os.Stderr, "Skipping --attachment %q: URLs are not supported here, only local file paths.\n", filePath)
|
||||
continue
|
||||
}
|
||||
data, readErr := os.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", filePath, readErr)
|
||||
}
|
||||
pending = append(pending, pendingAttachment{path: filePath, data: data})
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/issues", body, &result); err != nil {
|
||||
return fmt.Errorf("create issue: %w", err)
|
||||
}
|
||||
|
||||
// Upload attachments and link them to the newly created issue.
|
||||
// Failures here are partial-success: the issue exists already, so
|
||||
// turning a non-zero exit on the caller would invite a retry that
|
||||
// duplicates the issue. Warn on stderr and continue.
|
||||
issueID := strVal(result, "id")
|
||||
for _, filePath := range attachments {
|
||||
data, readErr := os.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", filePath, readErr)
|
||||
for _, att := range pending {
|
||||
if _, uploadErr := client.UploadFile(ctx, att.data, att.path, issueID); uploadErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: upload attachment %s failed (issue already created, %s): %v\n",
|
||||
att.path, strVal(result, "identifier"), uploadErr)
|
||||
continue
|
||||
}
|
||||
if _, uploadErr := client.UploadFile(ctx, data, filePath, issueID); uploadErr != nil {
|
||||
return fmt.Errorf("upload attachment %s: %w", filePath, uploadErr)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Uploaded %s\n", filePath)
|
||||
fmt.Fprintf(os.Stderr, "Uploaded %s\n", att.path)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
@@ -835,9 +875,18 @@ func runIssueCommentAdd(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Upload attachments and collect their IDs.
|
||||
// Upload attachments and collect their IDs. URLs are skipped with a
|
||||
// warning — `--attachment` only accepts local file paths, and a
|
||||
// markdown image URL embedded in agent-supplied content should never
|
||||
// be re-uploaded as if it were a file. Unlike `issue create`, this
|
||||
// path uploads BEFORE posting the comment, so a hard failure on a
|
||||
// real (local) attachment correctly aborts the whole call.
|
||||
var attachmentIDs []string
|
||||
for _, filePath := range attachments {
|
||||
if isHTTPURL(filePath) {
|
||||
fmt.Fprintf(os.Stderr, "Skipping --attachment %q: URLs are not supported here, only local file paths.\n", filePath)
|
||||
continue
|
||||
}
|
||||
data, readErr := os.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", filePath, readErr)
|
||||
|
||||
@@ -57,9 +57,10 @@ func buildQuickCreatePrompt(task Task) string {
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n")
|
||||
}
|
||||
b.WriteString("- project: omit. The platform will route the issue to the workspace default.\n")
|
||||
b.WriteString("- status: omit (defaults to `todo`).\n\n")
|
||||
b.WriteString("- status: omit (defaults to `todo`).\n")
|
||||
b.WriteString("- attachments: do NOT pass `--attachment`. The flag only accepts LOCAL file paths, and any image URL embedded in the user input is already part of the description as markdown — keep it inline in `--description` instead of trying to re-attach it. (Trying to pass `https://…` to `--attachment` will fail and look like a create error to you, but the issue may already exist; never retry `issue create` on that signal.)\n\n")
|
||||
b.WriteString("Output format:\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation.\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation. Do not retry it for any reason — even on a non-zero exit. The issue may already exist; another attempt would create a duplicate.\n")
|
||||
b.WriteString("- After it succeeds, print exactly one line: `Created MUL-<n>: <title>` and exit. No commentary, no follow-up tool calls.\n")
|
||||
b.WriteString("- Do NOT call `multica issue get` or `multica issue comment add` for this task — there is no issue to query or comment on prior to creation.\n")
|
||||
b.WriteString("- If the CLI returns an error, exit with that error as the only output. The platform writes a failure notification automatically; do not retry.\n")
|
||||
|
||||
Reference in New Issue
Block a user