mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* fix(cli): show the server's conflict message instead of the generic 409 template
Every 409 this API returns is a deterministic refusal that names its own fix
("a skill with this name already exists", "set parent_id (--parent) to <id>").
The CLI replaced all of them with a template that says the opposite — that the
state changed underneath you and you should re-fetch and retry. Agents took the
retry hint literally: GH #6264 reports 15+ identical retries over 10 minutes
followed by hours spent chasing an optimistic-concurrency theory that never
existed, and GH #5948 is a second user misdiagnosing the same way. MUL-4417 had
already written the useful message server-side; it just never reached anyone.
Route 409 through the same server-message extraction 400/422 already uses, so
roughly forty hand-written conflict messages across skills, agents, runtimes,
labels, projects and comments become visible by default. A body we cannot
recognize still falls back to the template, so this never dumps a raw response.
extractServerMessage now prefers prose over a bare identifier, because a few
endpoints put a stable code in "error" and the sentence in "message".
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): stop telling a wrong --parent that it posted a top-level comment
The reply guard returns one message for two different mistakes. A resumed
session that carries a previous turn's --parent forward (GH #6264) did not ask
for a top-level comment, but is told it did — which sends it looking for a
new-thread opt-in (GH #5383) instead of correcting the parent it already passed.
Split the copy: name the rejected parent when one was supplied, and keep the
existing top-level wording for the parentless case. Both still point at the
trigger comment to use.
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtime): return 500, not a 409 echo, when the update store fails
InitiateUpdate answered every UpdateStore.Create failure with a 409 carrying
err.Error(). The in-memory store only ever returns errUpdateInProgress, so this
looked safe — but the Redis store also wraps infrastructure failures as
"reserve active update: <dial error>" and "persist update request: <error>".
Surfacing 409 bodies in the CLI turns that into a user-visible leak of internal
addresses, and labels an outage as a conflict the caller could fix by retrying.
Classify instead: errUpdateInProgress keeps its 409 and its actionable message,
everything else is logged and answered with a 500 and fixed copy.
Also pins the prose-over-machine-code preference for validation bodies, which
the shared extractor applies to 400/422 as well as 409. Only the issue-table
endpoints are shaped that way and none is reachable from the CLI today, but the
change is intentional and should fail loudly if reverted.
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestCreateComment_TriggeredTaskRejectsTopLevelComment exercises the full
|
|
// CreateComment handler path (not just taskCoversReplyParent) for the trap
|
|
// reported in MUL-4417 / GH #5266: a comment-triggered task that posts a
|
|
// parentless, top-level comment on its own issue is rejected with a 409 whose
|
|
// message names the trigger comment and states that top-level comments are not
|
|
// allowed. Pinning the message here keeps it from silently drifting away from
|
|
// the behavior the CLI help now documents.
|
|
func TestCreateComment_TriggeredTaskRejectsTopLevelComment(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
fx := newRunningSquadLeaderTaskFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
|
|
"content": "dispatching a squad from this task",
|
|
})
|
|
r = withURLParam(r, "id", fx.IssueID)
|
|
r.Header.Set("X-Agent-ID", fx.LeaderID)
|
|
r.Header.Set("X-Task-ID", fx.TaskID)
|
|
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("CreateComment top-level: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
|
|
t.Fatalf("expected rejected top-level comment not to be stored, got %d", got)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode error response: %v", err)
|
|
}
|
|
msg, _ := body["error"].(string)
|
|
// Pin the three semantic pieces without locking the exact wording: why it
|
|
// was rejected, the comment to reply under, and the actionable fix. The last
|
|
// one guards against the guidance being dropped in a future edit.
|
|
for _, want := range []string{
|
|
"top-level comments", // reason
|
|
fx.TriggerCommentID, // the comment to reply under
|
|
"parent_id (--parent)", // actionable fix
|
|
} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Fatalf("409 message should contain %q, got %q", want, msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCreateComment_TriggeredTaskAllowsReplyUnderTrigger is the positive half:
|
|
// the same task replying under its trigger comment succeeds, proving the guard
|
|
// rejects only the top-level case and does not lock the whole issue for
|
|
// comments (MUL-4417 / GH #5266).
|
|
func TestCreateComment_TriggeredTaskAllowsReplyUnderTrigger(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
fx := newRunningSquadLeaderTaskFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
|
|
"content": "replying under the trigger comment",
|
|
"parent_id": fx.TriggerCommentID,
|
|
})
|
|
r = withURLParam(r, "id", fx.IssueID)
|
|
r.Header.Set("X-Agent-ID", fx.LeaderID)
|
|
r.Header.Set("X-Task-ID", fx.TaskID)
|
|
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment reply-under-trigger: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateComment_TriggeredTaskRejectsForeignParent covers the resumed-session
|
|
// drift in GH #6264: the task passes a --parent that is a real comment on its
|
|
// own issue but not one this run was given to answer. The refusal must name
|
|
// both the parent it rejected and the parent to use — and must NOT say a
|
|
// top-level comment was attempted, since that wording sent agents looking for a
|
|
// new-thread opt-in instead of correcting the --parent they already passed.
|
|
func TestCreateComment_TriggeredTaskRejectsForeignParent(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
fx := newRunningSquadLeaderTaskFixture(t)
|
|
|
|
// Must be a real comment on the same issue: a nonexistent id, or one from
|
|
// another issue, is refused earlier with a 400 and never reaches this guard.
|
|
var foreignParentID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'an earlier thread this task never owned', 'comment')
|
|
RETURNING id
|
|
`, fx.IssueID, testWorkspaceID, testUserID).Scan(&foreignParentID); err != nil {
|
|
t.Fatalf("create foreign parent comment: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
|
|
"content": "posting under a parent carried over from a previous turn",
|
|
"parent_id": foreignParentID,
|
|
})
|
|
r = withURLParam(r, "id", fx.IssueID)
|
|
r.Header.Set("X-Agent-ID", fx.LeaderID)
|
|
r.Header.Set("X-Task-ID", fx.TaskID)
|
|
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("CreateComment foreign parent: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
|
|
t.Fatalf("expected rejected comment not to be stored, got %d", got)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode error response: %v", err)
|
|
}
|
|
msg, _ := body["error"].(string)
|
|
for _, want := range []string{
|
|
foreignParentID, // the parent that was refused
|
|
fx.TriggerCommentID, // the parent to use instead
|
|
"parent_id (--parent)", // actionable fix
|
|
} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Fatalf("409 message should contain %q, got %q", want, msg)
|
|
}
|
|
}
|
|
if strings.Contains(msg, "top-level") {
|
|
t.Fatalf("409 message must not claim a top-level comment was attempted, got %q", msg)
|
|
}
|
|
}
|