mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
* feat(wakeups): add rule deadlines and expose the child-done system rule (MUL-7680)
Wakeups can now end: an absolute deadline (expires_at) or a relative wait
(expires_in_seconds) that restarts when the rule is re-enabled. When the
deadline passes first, the scheduler ends the rule; event rules with
on_timeout=wake run the target once with a wakeup.timeout fact. A timed-out
rule keeps disabled_at NULL so its timeout run stays claimable.
The implicit "wake the parent's assignee when a stage of sub-issues
finishes" behavior is now described by GET /api/issues/{id}/system-wakeups
and can be turned off or given a supplementary instruction per issue via
PUT /api/issues/{id}/system-wakeups/child_done. Rule reads fail open to the
previous behavior.
List responses add the creator (member or agent) and the new expiry fields.
The CLI gains --expires-in, --expires-at and --on-timeout.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(issues): let members create wakeups and show the child-done system rule (MUL-7680)
The issue sidebar's Wakeups section is always available on open issues and
gains a New wakeup popover. Members pick a condition (a time, a recurring
check with an end date, someone's reply, an agent's run ending, or raw
events), the agent to wake, the instruction, how often it fires, how long
to wait and what happens on timeout. It posts the same configuration agents
create with the CLI.
The parent's child-done wake appears as a System rule with its stage,
remaining sub-issues and target, a per-issue toggle and a supplementary
instruction. Rows show when a rule ends, timed-out rules read as such, and
details name who created the rule. Rule titles wrap to two lines instead of
truncating.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* docs(wakeups): document deadlines, member-created rules and the system rule (MUL-7680)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(wakeups): platform conditions, runaway protection and check-ins (MUL-7680)
Conditions: a wakeup can now wait for a fact the platform checks itself
(a status, assignee, label or property value; sub-issues or one stage
finishing; a linked PR's checks finishing or merging; another issue's
status). Related events only prompt an early evaluation; the rule wakes
its agent when the predicate becomes true, and a repeating rule fires
again only after it turned false or its facts changed. Finished PR checks
from before registration are ignored.
Runaway protection: repeating event rules stop after max_fires runs
(default 20), a rule pauses when its causal chain passes through it a
third time without a person in between, or when it starts 12 runs in an
hour. The reason is stored and shown.
Also: silent check-ins for every/cron checks (the run then posts no
fallback comment), wake now, delete, a per-rule run history, paused-rule
lists, timeline entries for created/triggered/timed-out/paused/check-in,
and source, paused scope, 7-day runs and child-done system rows in the
workspace list. The CLI gains --until-* conditions, --max-fires and the
trigger/delete/checkin/runs commands; the brief and wakeup prompt state
the check-in exception.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(issues): record child-done transitions in the status write (MUL-7680)
The child-done system rule ran after the status write committed and gave
up on any error, so a crash, deploy or transient failure lost the
parent's wake. A trigger now records each child's move into a closed
status in the writing transaction, for every writer. The request that
wrote it processes the rows right away and a scheduler sweep retries
anything left over; claims make the two paths process a transition
once.
The rule also follows a workspace default (settings key
system_wakeup_child_done) until an issue sets its own, the per-issue
update accepts partial bodies, and each trigger adds a timeline entry
that names its system comment.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(views): wakeup conditions, history, timeline and workspace list (MUL-7680)
Issue sidebar: members can create the platform-checked conditions (a field
reaching a value, sub-issues or a stage finishing, a linked PR's CI or merge,
another issue's status) and cap how often a repeating wait fires. Rule
details show the fire count, why the platform paused a rule, the latest runs
with silent-check notes, and offer wake now and delete.
The issue header says what the issue is waiting for ("Emacs 在等 Jiayuan 回复
+2") and opens the Wakeups section. Board and list cards use the same short
sentence, or flag a paused rule. Wakeup runs read as "由唤醒触发 · <condition>"
in the execution log, transcript and on the comments they post.
The timeline shows created, triggered, timed-out, paused and check-in entries
with the rule they came from; consecutive check-ins merge, and the child-done
entry stands in for its system comment, which it can reveal.
Automation → Issue wakeups adds a source column and filter, 7-day runs, a
paused scope with a banner, the child-done system rule on each waiting parent,
and "New wakeup" with an issue picker. Settings → Issue statuses sets the
system rule's workspace default. Copy in all five languages.
The server records the watched issue's identifier in other-issue conditions
and the rule's creator in timeline snapshots, and returns board summaries with
their condition.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* docs(wakeups): document conditions, runaway protection and check-ins (MUL-7680)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(wakeups): list system rules without a revision and localize statuses (MUL-7680)
System rule rows returned revision 0, which clients reject as a rule
revision; they now return null, and a list row with an invalid revision no
longer fails the whole page. Built-in statuses in conditions and the status
picker read in the viewer's language. Adds a browser test for a condition
created from the form, the header summary, the scheduler waking the agent
and the workspace list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(views): say why a rule paused without repeating "paused" (MUL-7680)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(i18n): use the product's task terms in wakeup copy (MUL-7680)
ja, ko and fr wakeup strings said イシュー / 이슈 / ticket; the product term
for an issue in those languages is タスク / 태스크 / tâche (and sub-tasks
accordingly), per the conventions page. French strings are rewritten for
the feminine noun.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* test(views): pin the shortcut platform in the wakeup form test (MUL-7680)
The send shortcut is primary+Enter, which is Ctrl on Linux CI runners, so
pressing Cmd+Enter only submitted on macOS.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(views): simplify the workspace wakeup list
Put the filters on one row: scope as a segmented control on the left,
source/trigger/agent and a search-as-you-type box on the right. "New
wakeup" moves to the page header, which drops the separate Search button.
Rows are single-line: the identifier sits before the title, frequency and
timezone move into the trigger tooltip, and selection, prompt edit and
the last run's transcript show on row hover or focus. The issue column
takes the remaining width and truncates, so the table fits the page.
Also fix creating from the list: the issue picker closes itself right
after reporting the selection, and that close cancelled the flow before
the form could open.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* refactor(server): run the child_done rule as a system issue wakeup
The parent-assignee wake on closed sub-issue stages was its own code path:
a system comment, a mention-style run, an override table and endpoints.
It is now an ordinary issue_wakeup row with system_rule set, sharing the
condition, receipts, runaway protection, timeline entries and run model of
people's rules.
- One children_done evaluator: a stage wakes the assignee when it closes
while a later stage waits; the wrap-up waits for every sub-issue,
unstaged ones included. People's sub-issue conditions read the same set.
- issue_child_event records closing, reopening, re-parenting and restaging
for every writer; the request processes it right after commit and the
sweep retries. User sub-issue rules become immediate too.
- The target is resolved when it fires: an agent run, a squad leader run,
an inbox notification for a member, or only the timeline entry.
- A parked (backlog) parent catches up when it leaves backlog; a waiting
run of the same agent is joined instead of queuing a second one; the
hourly limit pauses a runaway rule. No system comment is posted.
- The run gets the issue's instruction, else the workspace's, else the
built-in one, plus each stage's counts and the next stage.
- Workspace defaults move to GET/PUT /api/system-wakeups (owners and
admins) and apply to every rule nobody customized; turning a rule on
records what already holds so old facts do not fire.
- Existing open parents get their rule from a one-time backfill.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(views): workspace wakeup settings and sub-issue notifications
- Settings → Wakeups holds the sub-issue rule's workspace default and
default instruction; the toggle leaves Issue statuses.
- The issue's system rule shows who it reaches (a member is notified),
a platform pause, and the instruction it will use.
- Timeline entries say whether the assignee was woken, notified or joined
a waiting run. The folding of the old system comment is gone.
- Inbox renders the new children_done notification on web, desktop and
mobile.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(server): stop wakeups from repeating their agent's runs
Runs are serialized per issue and agent, so a wakeup never ran beside
another run of its agent, but it queued behind one and repeated it. Each
rule now checks two things before it starts a run:
- Acknowledged: every input came from the agent itself. Its own comments
and issue changes never wake it, and a condition its own unfinished run
on the issue satisfied does not wake it when the platform or the agent
set the rule up. This fixes #8849: a coordinator closing its own stage is
not woken again while that run is going. A person's condition rule still
runs afterwards, since the running agent lacks its instruction.
- Merged: when a run of the agent is already waiting to start on the issue
(assignment, comment, another rule), the rule's instruction and facts
join that run (context.wakeup_joined, sent to the daemon as
wakeup_joined and rendered for every prompt kind) instead of queuing
another. Turning the rule off or replacing it withdraws what it joined.
Sub-issue changes now record the run that made them, and hints and
condition.met inputs carry those runs so a satisfied condition knows
whether the agent caused it. A rule that names the agent itself as the
actor keeps waking it on its own changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* feat(views): show merged and acknowledged wakeups, hint assignee comment rules
- Timeline entries say when a wakeup joined the agent's waiting run or was
the agent's own doing, for people's rules and the sub-issue rule.
- The create form tells a member that a reply or comment rule for the
issue's agent assignee joins the run a comment already starts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(server): hand waiting wakeups to a run when it is claimed
A rule that fired while its agent had a run waiting used to write its
instruction into that run right away and consume its inputs. That let a
member's rule run under another member's identity, kept deleted rules'
instructions in the run, lost the inputs when the run was cancelled or
claimed by an older daemon, and skipped the fire cap and loop check.
Now the rule only waits for a run that runs as the same person its own
run would, keeping its inputs. When a daemon advertising
joined-wakeups-v1 claims that run, JoinWaitingWakeups rechecks each rule
(on, same agent and person, creator still allowed, not the agent's own
input, no loop), consumes its inputs, counts the fire toward max_fires,
adds its chain and records the merge. A re-claim drops entries of rules
turned off or changed since. Anything else leaves the rule its inputs to
start its own run.
A child_done rule created by the backfill or by processing a change now
treats sub-issues with unprocessed close events as still open, so the
backfill no longer swallows a close waiting for the sweep.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
* fix(server): consume joined wakeup inputs only once the run starts
A claim used to consume the inputs of the rules that joined the run. If
that claim did not go through, the run went back to the queue carrying
inputs the rules no longer had: turning the sub-issue rule off on the
issue or for the workspace (which only changes enabled) did not remove
them from the next claim, and cancelling the run lost a once rule that
had already ended.
A claim now reserves the inputs for the run (receipt task_id, still
unprocessed). The rule's next dispatch settles them: a run that started
consumes them as a merged firing (once ends, max_fires counts and can
pause, timeline entry); a run that ended without starting gives them
back; one that has not started keeps them. Turning a rule off or
changing it discards its pending inputs, reserved ones included, and a
later claim of the same run rechecks every entry and drops the ones
without reserved inputs or no longer allowed to reach the run.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
650 lines
22 KiB
Go
650 lines
22 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/remotemcp"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
func TestClient_IdentityHeaders_PostJSON(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
|
|
t.Errorf("expected X-Client-Platform daemon, got %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Client-Version"); got != "9.9.9" {
|
|
t.Errorf("expected X-Client-Version 9.9.9, got %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Client-OS"); got != normalizeGOOS(runtime.GOOS) {
|
|
t.Errorf("expected X-Client-OS %q, got %q", normalizeGOOS(runtime.GOOS), got)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
|
|
t.Errorf("expected Authorization Bearer tok, got %q", got)
|
|
}
|
|
capabilities := make(map[string]bool)
|
|
for _, capability := range strings.Split(r.Header.Get("X-Client-Capabilities"), ",") {
|
|
capabilities[strings.TrimSpace(capability)] = true
|
|
}
|
|
for _, want := range []string{
|
|
protocol.DaemonCapabilitySkillBundlesV1,
|
|
protocol.DaemonCapabilityCoalescedCommentsV1,
|
|
// The worktree gate is decided entirely from this header: if the
|
|
// daemon stops advertising it, every worktree task on this machine
|
|
// is cancelled with an upgrade prompt (MUL-5707). Pin it here so
|
|
// dropping it from the list can never be a silent change.
|
|
protocol.DaemonCapabilityLocalWorktreeV1,
|
|
// Same shape, opposite default: this daemon's brief names the
|
|
// merged multica-platform skill, and advertising that is what
|
|
// stops the server shipping it a redirect stub under the old name
|
|
// (MUL-6986). Dropping it would silently hand every task on this
|
|
// machine a skill it does not need; the failure is extra payload
|
|
// and a stale signpost, neither of which any other test would
|
|
// notice.
|
|
protocol.DaemonCapabilityPlatformSkillV1,
|
|
// Gates whether an automatic retry is handed its parent's workdir
|
|
// (MUL-7034). Dropping it silently sends those retries back to a
|
|
// fresh directory, losing the continuity nothing else would flag.
|
|
protocol.DaemonCapabilityCheckoutKeepsWorkV1,
|
|
// Without it the server never hands this daemon the wakeups that
|
|
// waited for its run; they start runs of their own instead.
|
|
protocol.DaemonCapabilityJoinedWakeupsV1,
|
|
} {
|
|
if !capabilities[want] {
|
|
t.Errorf("X-Client-Capabilities missing %q: %v", want, capabilities)
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"ok": "1"})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
c.SetToken("tok")
|
|
c.SetVersion("9.9.9")
|
|
|
|
if err := c.postJSON(context.Background(), "/api/daemon/test", map[string]any{}, nil); err != nil {
|
|
t.Fatalf("postJSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_IdentityHeaders_GetJSON(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
|
|
t.Errorf("expected X-Client-Platform daemon, got %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Client-Version"); got != "1.2.3" {
|
|
t.Errorf("expected X-Client-Version 1.2.3, got %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Client-OS"); got == "" {
|
|
t.Errorf("expected X-Client-OS to be set")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
c.SetToken("tok")
|
|
c.SetVersion("1.2.3")
|
|
|
|
var out map[string]any
|
|
if err := c.getJSON(context.Background(), "/api/daemon/test", &out); err != nil {
|
|
t.Fatalf("getJSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartTaskCapabilityNegotiationMixedVersions(t *testing.T) {
|
|
defer noSleepRetry(t)()
|
|
const prefix = `{"supplement_capability":"task-supplement-v1","issue":{"description":"`
|
|
const suffix = `"}}`
|
|
responseAtLimit := prefix + strings.Repeat("x", (1<<20)-len(prefix)-len(suffix)) + suffix
|
|
for _, tc := range []struct {
|
|
name string
|
|
response string
|
|
contentLength string
|
|
negotiated bool
|
|
wantError bool
|
|
retry bool
|
|
}{
|
|
{name: "empty response", response: ""},
|
|
{name: "truncated HTTP body", contentLength: "8", wantError: true, retry: true},
|
|
{name: "truncated response", response: `{"supplement_capability":`, wantError: true},
|
|
{name: "trailing garbage", response: `{"supplement_capability":"task-supplement-v1"}garbage`, wantError: true},
|
|
{name: "HTML response", response: `<html>Proxy error</html>`, wantError: true},
|
|
{name: "response at size limit", response: responseAtLimit, negotiated: true},
|
|
{name: "response exceeds size limit", response: responseAtLimit + " ", wantError: true},
|
|
{name: "old server task response", response: `{"id":"task-1","status":"running"}`, negotiated: false},
|
|
{name: "new server explicit capability", response: `{"supplement_capability":"task-supplement-v1"}`, negotiated: true},
|
|
} {
|
|
for _, mode := range []string{"legacy", "claim-fenced"} {
|
|
t.Run(tc.name+"/"+mode, func(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
var body struct {
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode start request: %v", err)
|
|
}
|
|
if len(body.Capabilities) != 1 || body.Capabilities[0] != protocol.DaemonCapabilityTaskSupplementV1 {
|
|
t.Errorf("capabilities = %#v", body.Capabilities)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if tc.contentLength != "" {
|
|
w.Header().Set("Content-Length", tc.contentLength)
|
|
}
|
|
_, _ = w.Write([]byte(tc.response))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
task := startTestClaim()
|
|
task.StartClaimSupported = mode == "claim-fenced"
|
|
got, err := NewClient(srv.URL).StartTask(context.Background(), task, protocol.DaemonCapabilityTaskSupplementV1)
|
|
if (err != nil) != tc.wantError {
|
|
t.Errorf("StartTask: %v", err)
|
|
}
|
|
if got != tc.negotiated {
|
|
t.Errorf("negotiated = %v, want %v", got, tc.negotiated)
|
|
}
|
|
wantCalls := 1
|
|
if tc.retry && task.StartClaimSupported {
|
|
wantCalls += len(startTaskRetrySchedule)
|
|
}
|
|
if got := int(calls.Load()); got != wantCalls {
|
|
t.Errorf("requests = %d, want %d", got, wantCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClient_ResolveRemoteMCPCredentialUsesExplicitDaemonToken(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer mdt_task_broker" {
|
|
t.Errorf("Authorization = %q, want short-lived daemon token", got)
|
|
}
|
|
if got := r.URL.Path; got != "/api/daemon/tasks/task-1/remote-mcp/contribution-1/credential" {
|
|
t.Errorf("path = %q", got)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"credential_header":"Authorization","credential":"Bearer upstream"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
c.SetToken("mul_owner_pat")
|
|
headers, err := c.ResolveRemoteMCPCredential(context.Background(), "mdt_task_broker", "task-1", "contribution-1")
|
|
if err != nil {
|
|
t.Fatalf("ResolveRemoteMCPCredential: %v", err)
|
|
}
|
|
if got := headers.Get("Authorization"); got != "Bearer upstream" {
|
|
t.Fatalf("resolved credential = %q", got)
|
|
}
|
|
if got := c.Token(); got != "mul_owner_pat" {
|
|
t.Fatalf("client PAT was mutated to %q", got)
|
|
}
|
|
}
|
|
|
|
// A Plugin's mcp hook shares this resolver and this broker with a workspace's
|
|
// own Remote MCP connections, but its credential lives in the Plugin's secret
|
|
// storage and a different route serves it. The contribution id is all the
|
|
// broker hands back at dial time, so the id carries the marker — and a
|
|
// connection without it must keep going to the original route.
|
|
func TestClient_ResolveRemoteMCPCredentialRoutesPluginContributions(t *testing.T) {
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = append(seen, r.URL.Path)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"credential_header":"Authorization","credential":"Bearer upstream"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
for _, contribution := range []string{"contribution-1", remotemcp.PluginContributionPrefix + "install-1:toolbox"} {
|
|
if _, err := c.ResolveRemoteMCPCredential(context.Background(), "mdt_task_broker", "task-1", contribution); err != nil {
|
|
t.Fatalf("resolve %q: %v", contribution, err)
|
|
}
|
|
}
|
|
|
|
want := []string{
|
|
"/api/daemon/tasks/task-1/remote-mcp/contribution-1/credential",
|
|
"/api/daemon/tasks/task-1/plugin-mcp/plugin:install-1:toolbox/credential",
|
|
}
|
|
for i, path := range want {
|
|
if seen[i] != path {
|
|
t.Fatalf("request %d went to %q, want %q", i, seen[i], path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClient_VersionOmittedWhenUnset(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
|
|
t.Errorf("expected X-Client-Platform daemon, got %q", got)
|
|
}
|
|
// SetVersion not called → header must be omitted (not "").
|
|
if vals := r.Header.Values("X-Client-Version"); len(vals) != 0 {
|
|
t.Errorf("expected X-Client-Version absent, got %v", vals)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
if err := c.postJSON(context.Background(), "/api/daemon/test", nil, nil); err != nil {
|
|
t.Fatalf("postJSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_ListWorkspacesUsesDaemonEndpointAndETag(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/daemon/workspaces" {
|
|
t.Errorf("path = %q, want /api/daemon/workspaces", r.URL.Path)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
call := calls.Add(1)
|
|
if call == 1 {
|
|
if got := r.Header.Get("If-None-Match"); got != "" {
|
|
t.Errorf("first If-None-Match = %q, want empty", got)
|
|
}
|
|
w.Header().Set("ETag", `W/"workspace-v1"`)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"id":"ws-1","name":"One"}]`))
|
|
return
|
|
}
|
|
if got := r.Header.Get("If-None-Match"); got != `W/"workspace-v1"` {
|
|
t.Errorf("If-None-Match = %q, want cached ETag", got)
|
|
}
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
first, err := c.ListWorkspaces(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("first ListWorkspaces: %v", err)
|
|
}
|
|
second, err := c.ListWorkspaces(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second ListWorkspaces: %v", err)
|
|
}
|
|
if len(first) != 1 || len(second) != 1 || second[0] != first[0] {
|
|
t.Fatalf("cached workspaces mismatch: first=%+v second=%+v", first, second)
|
|
}
|
|
}
|
|
|
|
func TestClient_ListWorkspacesFallsBackToLegacyEndpointOnce(t *testing.T) {
|
|
var daemonCalls atomic.Int32
|
|
var legacyCalls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/daemon/workspaces":
|
|
daemonCalls.Add(1)
|
|
http.NotFound(w, r)
|
|
case "/api/workspaces":
|
|
legacyCalls.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"id":"ws-legacy","name":"Legacy"}]`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
for i := 0; i < 2; i++ {
|
|
workspaces, err := c.ListWorkspaces(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListWorkspaces call %d: %v", i+1, err)
|
|
}
|
|
if len(workspaces) != 1 || workspaces[0].ID != "ws-legacy" {
|
|
t.Fatalf("workspaces = %+v, want legacy response", workspaces)
|
|
}
|
|
}
|
|
if got := daemonCalls.Load(); got != 1 {
|
|
t.Fatalf("daemon endpoint calls = %d, want 1", got)
|
|
}
|
|
if got := legacyCalls.Load(); got != 2 {
|
|
t.Fatalf("legacy endpoint calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
// noSleepRetry replaces retrySleep with an immediate no-op so tests don't
|
|
// actually wait the 4s/8s/16s/... backoffs. Returns a restore func.
|
|
func noSleepRetry(t *testing.T) func() {
|
|
t.Helper()
|
|
prev := retrySleep
|
|
retrySleep = func(ctx context.Context, _ time.Duration) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return func() { retrySleep = prev }
|
|
}
|
|
|
|
func TestIsTransientError(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil is not transient", nil, false},
|
|
{"5xx is transient", &requestError{StatusCode: http.StatusBadGateway}, true},
|
|
{"503 is transient", &requestError{StatusCode: http.StatusServiceUnavailable}, true},
|
|
{"408 is transient", &requestError{StatusCode: http.StatusRequestTimeout}, true},
|
|
{"429 is transient", &requestError{StatusCode: http.StatusTooManyRequests}, true},
|
|
{"400 is permanent", &requestError{StatusCode: http.StatusBadRequest}, false},
|
|
{"401 is permanent", &requestError{StatusCode: http.StatusUnauthorized}, false},
|
|
{"404 is permanent", &requestError{StatusCode: http.StatusNotFound}, false},
|
|
{"transport-level error is transient", errors.New("connection reset by peer"), true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isTransientError(tc.err); got != tc.want {
|
|
t.Fatalf("isTransientError(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsIssueGCBatchUnsupported(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{
|
|
name: "old server unmatched route",
|
|
err: &requestError{StatusCode: http.StatusNotFound, Body: "404 page not found"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "workspace access denied",
|
|
err: &requestError{StatusCode: http.StatusNotFound, Body: `{"error":"not found"}`},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "transient server error",
|
|
err: &requestError{StatusCode: http.StatusInternalServerError, Body: "failure"},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isIssueGCBatchUnsupported(tt.err); got != tt.want {
|
|
t.Fatalf("isIssueGCBatchUnsupported() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostJSONWithRetry_TransientThenSuccess(t *testing.T) {
|
|
defer noSleepRetry(t)()
|
|
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
n := calls.Add(1)
|
|
if n < 3 {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
schedule := []time.Duration{time.Nanosecond, time.Nanosecond, time.Nanosecond}
|
|
if err := c.postJSONWithRetry(context.Background(), "/x", map[string]any{}, nil, schedule); err != nil {
|
|
t.Fatalf("postJSONWithRetry: %v", err)
|
|
}
|
|
if got := calls.Load(); got != 3 {
|
|
t.Fatalf("expected 3 attempts (2 transient + 1 success), got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestFailTask_RetriesOnTransient5xxThenSucceeds pins the callback half of
|
|
// MUL-5305 Must-fix 1: FailTask's terminal transaction is now the sole
|
|
// persistence point for the withheld session and continuity-gap flag, so if the
|
|
// server returns a transient 5xx (the terminal tx rolled back), the daemon MUST
|
|
// retry until it lands — a 400 would make it bail immediately
|
|
// (TestPostJSONWithRetry_PermanentBailsImmediately) and drop the gap forever.
|
|
func TestFailTask_RetriesOnTransient5xxThenSucceeds(t *testing.T) {
|
|
defer noSleepRetry(t)()
|
|
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if calls.Add(1) < 3 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
if err := c.FailTask(context.Background(), "task-1", "boom", "", "", "", "timeout", true, "", ""); err != nil {
|
|
t.Fatalf("FailTask: %v", err)
|
|
}
|
|
if got := calls.Load(); got != 3 {
|
|
t.Fatalf("expected 3 attempts (2 transient 5xx + 1 success), got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestPostJSONWithRetry_TransientExhausts(t *testing.T) {
|
|
defer noSleepRetry(t)()
|
|
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
schedule := []time.Duration{time.Nanosecond, time.Nanosecond}
|
|
err := c.postJSONWithRetry(context.Background(), "/x", map[string]any{}, nil, schedule)
|
|
if err == nil {
|
|
t.Fatal("expected error after schedule exhausted, got nil")
|
|
}
|
|
if !isTransientError(err) {
|
|
t.Fatalf("expected transient error, got %v", err)
|
|
}
|
|
if got := calls.Load(); got != int32(len(schedule)+1) {
|
|
t.Fatalf("expected %d attempts (initial + %d retries), got %d", len(schedule)+1, len(schedule), got)
|
|
}
|
|
}
|
|
|
|
func TestPostJSONWithRetry_PermanentBailsImmediately(t *testing.T) {
|
|
defer noSleepRetry(t)()
|
|
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
schedule := []time.Duration{time.Nanosecond, time.Nanosecond, time.Nanosecond}
|
|
err := c.postJSONWithRetry(context.Background(), "/x", map[string]any{}, nil, schedule)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Fatalf("expected exactly 1 attempt on permanent error, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestPostJSONWithRetry_CtxCancelStopsRetries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Use the real sleeper here so we can observe a cancel preempting it.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
w.(http.Flusher).Flush()
|
|
// Cancel only once the first attempt has been answered: it lands while
|
|
// the client finishes that response or in the 1s retry sleep after it,
|
|
// never before the first attempt, and no second attempt can start.
|
|
cancel()
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
schedule := []time.Duration{time.Second, time.Second, time.Second}
|
|
start := time.Now()
|
|
err := c.postJSONWithRetry(ctx, "/x", map[string]any{}, nil, schedule)
|
|
elapsed := time.Since(start)
|
|
if err == nil {
|
|
t.Fatal("expected error after ctx cancel, got nil")
|
|
}
|
|
if elapsed > 750*time.Millisecond {
|
|
t.Fatalf("expected ctx cancel to short-circuit retry, took %s", elapsed)
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Fatalf("expected exactly 1 attempt before cancel, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeGOOS(t *testing.T) {
|
|
cases := map[string]string{
|
|
"darwin": "macos",
|
|
"windows": "windows",
|
|
"linux": "linux",
|
|
"freebsd": "freebsd",
|
|
}
|
|
for in, want := range cases {
|
|
if got := normalizeGOOS(in); got != want {
|
|
t.Errorf("normalizeGOOS(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTerminalReportsCarryRetiredSessionID pins the daemon half of the
|
|
// retire-session contract (GH #6066). Before it, a terminal report could only
|
|
// say "here is a session" or say nothing — and saying nothing was how a
|
|
// recovered turn silently left the poisoned id selectable. The completed path
|
|
// matters most: that is exactly the case where a fresh-session retry SUCCEEDED
|
|
// and the abandoned transcript would otherwise survive on an older row.
|
|
func TestTerminalReportsCarryRetiredSessionID(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
endpoint string
|
|
call func(*Client) error
|
|
}{
|
|
{
|
|
name: "complete",
|
|
endpoint: "/api/daemon/tasks/task-1/complete",
|
|
call: func(c *Client) error {
|
|
return c.CompleteTask(context.Background(), "task-1", "done", "", "", "/tmp/wd", false, "POISONED-S", "")
|
|
},
|
|
},
|
|
{
|
|
name: "fail",
|
|
endpoint: "/api/daemon/tasks/task-1/fail",
|
|
call: func(c *Client) error {
|
|
return c.FailTask(context.Background(), "task-1", "boom", "", "/tmp/wd", "", "api_invalid_request", false, "POISONED-S", "")
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var body map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != tc.endpoint {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if err := tc.call(NewClient(srv.URL)); err != nil {
|
|
t.Fatalf("terminal report: %v", err)
|
|
}
|
|
if got, _ := body["retired_session_id"].(string); got != "POISONED-S" {
|
|
t.Fatalf("retired_session_id = %v, want POISONED-S (body: %v)", body["retired_session_id"], body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTerminalReportsOmitEmptyRetiredSessionID keeps the common case off the
|
|
// wire: nearly every run retires nothing, and an empty field would be
|
|
// indistinguishable from "retire the empty session".
|
|
func TestTerminalReportsOmitEmptyRetiredSessionID(t *testing.T) {
|
|
var body map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if err := NewClient(srv.URL).CompleteTask(context.Background(), "task-1", "done", "", "sess-1", "/tmp/wd", false, "", ""); err != nil {
|
|
t.Fatalf("CompleteTask: %v", err)
|
|
}
|
|
if _, present := body["retired_session_id"]; present {
|
|
t.Fatalf("retired_session_id must be omitted when nothing was retired, got %v", body)
|
|
}
|
|
}
|
|
|
|
func TestTerminalReportsCarryDurableWorkDir(t *testing.T) {
|
|
const durableWorkDir = "/Users/dev/project"
|
|
for _, tc := range []struct {
|
|
name string
|
|
call func(*Client) error
|
|
}{
|
|
{
|
|
name: "complete",
|
|
call: func(c *Client) error {
|
|
return c.CompleteTask(context.Background(), "task-1", "done", "", "", "/tmp/wd", false, "", durableWorkDir)
|
|
},
|
|
},
|
|
{
|
|
name: "fail",
|
|
call: func(c *Client) error {
|
|
return c.FailTask(context.Background(), "task-1", "boom", "", "/tmp/wd", "", "agent_error", false, "", durableWorkDir)
|
|
},
|
|
},
|
|
{
|
|
name: "cancel ack",
|
|
call: func(c *Client) error {
|
|
return c.AckTaskCancelled(context.Background(), "task-1", TaskCancelAck{DurableWorkDir: durableWorkDir})
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var body map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if err := tc.call(NewClient(srv.URL)); err != nil {
|
|
t.Fatalf("terminal report: %v", err)
|
|
}
|
|
if got := body["durable_work_dir"]; got != durableWorkDir {
|
|
t.Fatalf("durable_work_dir = %v, want %q (body: %v)", got, durableWorkDir, body)
|
|
}
|
|
})
|
|
}
|
|
}
|