279e15b318
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment) The api_write manifest capability was a stored no-op. Wire the deferred phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task, Slack slash-command task creation, refactor fan-out) can create and update tasks and post comments. - SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer; CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk). - Gating: api_write:tasks gates Create/UpdateTask, api_write:comments gates CreateComment; undeclared -> gRPC PermissionDenied, per-method and independent of the read caps on the same resource. - Writes route through the first-party service layer (task service for tasks, office service for comments) via backendapp adapters, never a repository -- so task.*/comment-created events fire and WS updates. Server stamps source="plugin:<id>"; CreateTask resolves workspace/ workflow defaults and best-effort honors start_agent; UpdateTask uses a conservative title/description/state/workflow_step_id mask. - Fixture plugins exercise a CreateTask+CreateComment round-trip; in-process, real-broker wire, and subprocess tests cover the round-trip and per-capability denial. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved writes deferred -> implemented; ADR 0043 amended with the concrete service-layer routing decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment) The office TaskComment write was the wrong primitive — it's an office/ dashboard construct, not a way to message the agent working a task, and it only did anything under office reactivity. Replace the CreateComment write RPC with SendMessage, which delivers a prompt to a task session through the orchestrator's real delivery path (the same one message_task uses). - Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest) returns SendMessageResponse{session_id, status}; regenerated stubs. - SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID, text) added (pairs with Messages().List), returning MessageDispatch. - Gating: api_write:comments dropped; SendMessage gated api_write:messages (independent of api_read:messages on the same accessor, like Tasks()). - Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the target session (explicit id verified against the task, or the primary session), records a user message stamped source="plugin:<id>", then dispatches by state — running → queue; idle/completed → prompt (resume if the agent process is gone); never-started → launch; failed dispatch deletes the recorded message. Wired via SetWriteDeps (live-read). - Tests: adapter state-machine unit tests (queue/start/prompt/resume/ delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process gating, wire, and subprocess round-trips updated to SendMessage. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the ADR 0043 amendment updated from comment-create to message-send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): address PR review — nil guards, detached contexts, state validation Follow-up fixes from the #1970 review (Claude + CodeRabbit bots): - grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned value before toProto() so a custom/test Host returning (nil, nil) yields a gRPC Internal error, not a server panic (matches GetTask's pattern). - host.go: fix stale server doc comment (Comments()/CreateComment → Messages()/SendMessage). - startTaskBestEffort and the post-resume wait+retry now run on context.WithoutCancel(ctx): the task/resume is already committed, so a plugin's incoming deadline must not silently abort the launch/resume. - pluginsTaskWriterAdapter.UpdateTask validates the state string against the known task-state enum (excluding orchestrator-owned SCHEDULING) and returns InvalidArgument, so a plugin typo can't forward a bogus state to the service. - Tests: add the resume-wait-timeout path (message deleted so a retry can't duplicate), and a dedicated pluginsTaskWriterAdapter test covering source→metadata mapping and state validation (both adapters now depend on narrow interfaces for testability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): launch start_agent asynchronously so CreateTask doesn't block Review nit follow-up: startTaskBestEffort called StartTask synchronously, so with the detached context the plugin's CreateTask RPC blocked on the orchestrator's full inline launch path (office lookup, state update, workflow step move, task fetch) with no bound — and the "asynchronous" comment was wrong. Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak), mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and StartTask returns immediately. The plugin host stays a simple synchronous call (its taskStarter contract now documents "returns promptly, launches async"). Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch completes, detaches from a cancelled caller context, and swallows launch errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup Addresses the final PR #1970 review nits: - adapters_plugin_starter_test.go: register the block-channel release via t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md. - GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules (title required, id required, NotFound on missing task, invalid-state reject). - spec.md: document that a failed SendMessage dispatch deletes the recorded message, plus the required-field/NotFound/terminal-session rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
3.7 KiB
Go
114 lines
3.7 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
type fakePluginStarter struct {
|
|
started chan string
|
|
err error
|
|
// blockUntil, when non-nil, holds StartTask open until closed, so a test
|
|
// can prove the adapter returned before the (slow) launch finished.
|
|
blockUntil chan struct{}
|
|
}
|
|
|
|
func (f *fakePluginStarter) StartTask(ctx context.Context, taskID, _, _, _, _, _, _ string, _, _ bool, _ []v1.MessageAttachment) (*orchexecutor.TaskExecution, error) {
|
|
if f.blockUntil != nil {
|
|
select {
|
|
case <-f.blockUntil:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
select {
|
|
case f.started <- taskID:
|
|
default:
|
|
}
|
|
return &orchexecutor.TaskExecution{}, f.err
|
|
}
|
|
|
|
func newStarterAdapter(t *testing.T, orch pluginStarterService) pluginsTaskStarterAdapter {
|
|
t.Helper()
|
|
log, err := logger.NewFromZap(zap.NewNop())
|
|
require.NoError(t, err)
|
|
return pluginsTaskStarterAdapter{orch: orch, log: log}
|
|
}
|
|
|
|
func TestPluginsStarter_LaunchesAsynchronously(t *testing.T) {
|
|
orch := &fakePluginStarter{started: make(chan string, 1)}
|
|
a := newStarterAdapter(t, orch)
|
|
|
|
require.NoError(t, a.StartTask(context.Background(), "task-1"))
|
|
select {
|
|
case id := <-orch.started:
|
|
require.Equal(t, "task-1", id)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("orchestrator StartTask was never invoked by the fire-and-forget goroutine")
|
|
}
|
|
}
|
|
|
|
// TestPluginsStarter_ReturnsBeforeLaunchCompletes proves StartTask does not
|
|
// block the caller on the (potentially long) orchestrator launch path.
|
|
func TestPluginsStarter_ReturnsBeforeLaunchCompletes(t *testing.T) {
|
|
block := make(chan struct{})
|
|
// Register the release before the goroutine can block on it (and before any
|
|
// t.Fatal path), so an early failure can't strand the launch goroutine
|
|
// until AgentLaunchTimeout. sync.Once makes the explicit release below and
|
|
// this cleanup safe to both run.
|
|
var releaseOnce sync.Once
|
|
release := func() { releaseOnce.Do(func() { close(block) }) }
|
|
t.Cleanup(release)
|
|
|
|
orch := &fakePluginStarter{started: make(chan string, 1), blockUntil: block}
|
|
a := newStarterAdapter(t, orch)
|
|
|
|
require.NoError(t, a.StartTask(context.Background(), "task-1"), "must return immediately even while the launch is in flight")
|
|
release() // release the launch so the goroutine can finish
|
|
select {
|
|
case <-orch.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("launch goroutine never completed after unblock")
|
|
}
|
|
}
|
|
|
|
// TestPluginsStarter_DetachesFromCallerContext proves a cancelled caller
|
|
// context does not abort the launch — the task already exists, so the launch
|
|
// runs on a detached context.
|
|
func TestPluginsStarter_DetachesFromCallerContext(t *testing.T) {
|
|
orch := &fakePluginStarter{started: make(chan string, 1)}
|
|
a := newStarterAdapter(t, orch)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel BEFORE the call
|
|
require.NoError(t, a.StartTask(ctx, "task-1"))
|
|
select {
|
|
case id := <-orch.started:
|
|
require.Equal(t, "task-1", id, "launch proceeds despite the cancelled caller context")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("launch was aborted by the cancelled caller context")
|
|
}
|
|
}
|
|
|
|
func TestPluginsStarter_LaunchErrorIsSwallowed(t *testing.T) {
|
|
orch := &fakePluginStarter{started: make(chan string, 1), err: errors.New("no executor")}
|
|
a := newStarterAdapter(t, orch)
|
|
|
|
require.NoError(t, a.StartTask(context.Background(), "task-1"), "a launch error is best-effort, never surfaced")
|
|
select {
|
|
case <-orch.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("orchestrator StartTask was never invoked")
|
|
}
|
|
}
|