fix: schedule agent-created office tasks (#1605)
This commit is contained in:
@@ -251,6 +251,7 @@ func (s *AgentService) CreateAgentInstanceWithCaller(
|
||||
if err := s.validateAgentCreate(ctx, agent); err != nil {
|
||||
return err
|
||||
}
|
||||
inheritCallerExecutorPreference(agent, callerAgent)
|
||||
s.prepareAgentDefaults(agent)
|
||||
s.seedDefaultSkills(ctx, agent)
|
||||
|
||||
@@ -264,6 +265,31 @@ func (s *AgentService) CreateAgentInstanceWithCaller(
|
||||
return s.persistAgent(ctx, agent)
|
||||
}
|
||||
|
||||
func inheritCallerExecutorPreference(
|
||||
agent *models.AgentInstance,
|
||||
callerAgent *models.AgentInstance,
|
||||
) {
|
||||
if agent == nil || callerAgent == nil {
|
||||
return
|
||||
}
|
||||
if !executorPreferenceEmpty(agent.ExecutorPreference) {
|
||||
return
|
||||
}
|
||||
if executorPreferenceEmpty(callerAgent.ExecutorPreference) {
|
||||
return
|
||||
}
|
||||
agent.ExecutorPreference = callerAgent.ExecutorPreference
|
||||
}
|
||||
|
||||
func executorPreferenceEmpty(raw string) bool {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "", "{}", "null":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AgentService) prepareAgentDefaults(agent *models.AgentInstance) {
|
||||
if agent.ID == "" {
|
||||
agent.ID = uuid.New().String()
|
||||
|
||||
@@ -118,6 +118,37 @@ func TestCreateAgentInstanceWithCaller_PersistsPendingAgentWhenApprovalRequired(
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentInstanceWithCaller_InheritsCallerExecutorPreference(t *testing.T) {
|
||||
svc, repo := newTestAgentService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
caller := &models.AgentInstance{
|
||||
ID: "creator-1",
|
||||
WorkspaceID: "ws-1",
|
||||
Name: "CEO",
|
||||
Role: models.AgentRoleCEO,
|
||||
ExecutorPreference: `{"type":"local_pc"}`,
|
||||
}
|
||||
agent := &models.AgentInstance{
|
||||
WorkspaceID: "ws-1",
|
||||
Name: "Worker",
|
||||
Role: models.AgentRoleWorker,
|
||||
}
|
||||
|
||||
if err := svc.CreateAgentInstanceWithCaller(ctx, agent, caller, "delegate"); err != nil {
|
||||
t.Fatalf("CreateAgentInstanceWithCaller: %v", err)
|
||||
}
|
||||
|
||||
stored, err := repo.GetAgentInstance(ctx, agent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgentInstance: %v", err)
|
||||
}
|
||||
if stored.ExecutorPreference != caller.ExecutorPreference {
|
||||
t.Fatalf("executor_preference = %q, want inherited %q",
|
||||
stored.ExecutorPreference, caller.ExecutorPreference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentInstanceWithCaller_UICreateBypassesGovernance(t *testing.T) {
|
||||
svc, _ := newTestAgentService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -506,6 +506,22 @@ func TestInbox_TaskReviewRequest_AgentScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInbox_TaskReviewRequest_IgnoresRunnerOnlyTask(t *testing.T) {
|
||||
deps := newTestDeps(t)
|
||||
insertTestTaskWithAssignee(t, deps.db, "runner-only", "ws-i", "Runner only",
|
||||
"in_progress", 2, "agent-runner")
|
||||
|
||||
items, err := deps.svc.GetInboxItems(context.Background(), "ws-i")
|
||||
if err != nil {
|
||||
t.Fatalf("inbox: %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Type == "task_review_request" && it.EntityID == "runner-only" {
|
||||
t.Fatalf("runner-only task produced review request item: %#v", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers --
|
||||
|
||||
// mustAddParticipant inserts a participant row directly via the repo.
|
||||
|
||||
@@ -338,16 +338,23 @@ func (s *DashboardService) buildReviewRequestItem(
|
||||
}
|
||||
}
|
||||
|
||||
// viewerRoles returns the participant roles the viewer holds on the
|
||||
// task. For the singleton human user (viewerAgentID == "") every role
|
||||
// listed on the task is returned (the human is implicitly all-roles).
|
||||
// For an agent, only roles where the agent appears in participants.
|
||||
// viewerRoles returns pending decision roles for the viewer. Only
|
||||
// decision-required reviewer/approver participants are eligible. For
|
||||
// the singleton human user (viewerAgentID == ""), every eligible role
|
||||
// is returned; for an agent, only eligible roles where that agent
|
||||
// appears in participants are returned.
|
||||
func (s *DashboardService) viewerRoles(
|
||||
parts []sqlite.Participant, viewerAgentID string,
|
||||
) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
for _, p := range parts {
|
||||
if !p.DecisionRequired {
|
||||
continue
|
||||
}
|
||||
if p.Role != models.ParticipantRoleReviewer && p.Role != models.ParticipantRoleApprover {
|
||||
continue
|
||||
}
|
||||
if viewerAgentID != "" && p.AgentProfileID != viewerAgentID {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ import (
|
||||
// don't need to learn about workflow_step rows. CreatedAt is best-effort:
|
||||
// workflow_step_participants doesn't store it, so we emit zero time.
|
||||
type Participant struct {
|
||||
TaskID string `db:"task_id" json:"task_id"`
|
||||
AgentProfileID string `db:"agent_profile_id" json:"agent_profile_id"`
|
||||
Role string `db:"role" json:"role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
TaskID string `db:"task_id" json:"task_id"`
|
||||
AgentProfileID string `db:"agent_profile_id" json:"agent_profile_id"`
|
||||
Role string `db:"role" json:"role"`
|
||||
DecisionRequired bool `db:"decision_required" json:"decision_required"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// stepIDForTask resolves the current workflow_step_id for the given task.
|
||||
@@ -112,7 +113,7 @@ func (r *Repository) ListTaskParticipants(ctx context.Context, taskID, role stri
|
||||
return []Participant{}, nil
|
||||
}
|
||||
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(`
|
||||
SELECT task_id, agent_profile_id, role
|
||||
SELECT task_id, agent_profile_id, role, decision_required
|
||||
FROM workflow_step_participants
|
||||
WHERE step_id = ? AND role = ?
|
||||
AND (task_id = '' OR task_id = ?)
|
||||
@@ -126,15 +127,24 @@ func (r *Repository) ListTaskParticipants(ctx context.Context, taskID, role stri
|
||||
for rows.Next() {
|
||||
var p Participant
|
||||
var rowTask sql.NullString
|
||||
if err := rows.Scan(&rowTask, &p.AgentProfileID, &p.Role); err != nil {
|
||||
var decisionRequired int
|
||||
if err := rows.Scan(
|
||||
&rowTask,
|
||||
&p.AgentProfileID,
|
||||
&p.Role,
|
||||
&decisionRequired,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Project the canonical task_id into the row (template-level rows
|
||||
// have task_id = ''; we surface them as participants of the input task).
|
||||
p.TaskID = taskID
|
||||
if rowTask.Valid {
|
||||
p.TaskID = rowTask.String
|
||||
}
|
||||
p.DecisionRequired = decisionRequired != 0
|
||||
out = append(out, p)
|
||||
}
|
||||
return mergeOfficeParticipants(out), rows.Err()
|
||||
return projectOfficeParticipants(taskID, mergeOfficeParticipants(out)), rows.Err()
|
||||
}
|
||||
|
||||
// ListAllTaskParticipants returns every participant for a task across both
|
||||
@@ -149,7 +159,7 @@ func (r *Repository) ListAllTaskParticipants(ctx context.Context, taskID string)
|
||||
return []Participant{}, nil
|
||||
}
|
||||
rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(`
|
||||
SELECT task_id, agent_profile_id, role
|
||||
SELECT task_id, agent_profile_id, role, decision_required
|
||||
FROM workflow_step_participants
|
||||
WHERE step_id = ?
|
||||
AND (task_id = '' OR task_id = ?)
|
||||
@@ -163,13 +173,22 @@ func (r *Repository) ListAllTaskParticipants(ctx context.Context, taskID string)
|
||||
for rows.Next() {
|
||||
var p Participant
|
||||
var rowTask sql.NullString
|
||||
if err := rows.Scan(&rowTask, &p.AgentProfileID, &p.Role); err != nil {
|
||||
var decisionRequired int
|
||||
if err := rows.Scan(
|
||||
&rowTask,
|
||||
&p.AgentProfileID,
|
||||
&p.Role,
|
||||
&decisionRequired,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TaskID = taskID
|
||||
if rowTask.Valid {
|
||||
p.TaskID = rowTask.String
|
||||
}
|
||||
p.DecisionRequired = decisionRequired != 0
|
||||
out = append(out, p)
|
||||
}
|
||||
return mergeOfficeParticipants(out), rows.Err()
|
||||
return projectOfficeParticipants(taskID, mergeOfficeParticipants(out)), rows.Err()
|
||||
}
|
||||
|
||||
// mergeOfficeParticipants enforces per-task precedence: when a
|
||||
@@ -180,20 +199,26 @@ func mergeOfficeParticipants(rows []Participant) []Participant {
|
||||
if len(rows) <= 1 {
|
||||
return rows
|
||||
}
|
||||
// Since both flavours scan into the same Participant shape (task_id is
|
||||
// already projected to the input taskID), we can't distinguish them by
|
||||
// task_id alone. The merge is therefore a uniqueness collapse on
|
||||
// (role, agent_profile_id) — each unique key is kept once.
|
||||
type key struct{ role, agent string }
|
||||
seen := make(map[key]bool, len(rows))
|
||||
chosen := make(map[key]int, len(rows))
|
||||
out := make([]Participant, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
k := key{p.Role, p.AgentProfileID}
|
||||
if seen[k] {
|
||||
if idx, ok := chosen[k]; ok {
|
||||
if out[idx].TaskID == "" && p.TaskID != "" {
|
||||
out[idx] = p
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
chosen[k] = len(out)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectOfficeParticipants(taskID string, rows []Participant) []Participant {
|
||||
for i := range rows {
|
||||
rows[i].TaskID = taskID
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListAllTaskParticipants_PrefersPerTaskParticipant(t *testing.T) {
|
||||
repo := newTestRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := repo.ExecRaw(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT DEFAULT '',
|
||||
workflow_step_id TEXT DEFAULT ''
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create tasks table: %v", err)
|
||||
}
|
||||
if _, err := repo.ExecRaw(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS workflow_step_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
step_id TEXT NOT NULL,
|
||||
task_id TEXT DEFAULT '',
|
||||
role TEXT NOT NULL,
|
||||
agent_profile_id TEXT DEFAULT '',
|
||||
decision_required INTEGER DEFAULT 0,
|
||||
position INTEGER DEFAULT 0
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create workflow_step_participants table: %v", err)
|
||||
}
|
||||
if _, err := repo.ExecRaw(ctx, `
|
||||
INSERT INTO tasks (id, workspace_id, workflow_step_id)
|
||||
VALUES ('task-participants', 'ws-1', 'step-participants')
|
||||
`); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := repo.ExecRaw(ctx, `
|
||||
INSERT INTO workflow_step_participants
|
||||
(id, step_id, task_id, role, agent_profile_id, decision_required, position)
|
||||
VALUES
|
||||
('a-template', 'step-participants', '', 'reviewer', 'agent-reviewer', 0, 0),
|
||||
('z-task', 'step-participants', 'task-participants', 'reviewer', 'agent-reviewer', 1, 0)
|
||||
`); err != nil {
|
||||
t.Fatalf("insert participants: %v", err)
|
||||
}
|
||||
|
||||
participants, err := repo.ListAllTaskParticipants(ctx, "task-participants")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllTaskParticipants: %v", err)
|
||||
}
|
||||
if len(participants) != 1 {
|
||||
t.Fatalf("participants = %d, want 1: %#v", len(participants), participants)
|
||||
}
|
||||
got := participants[0]
|
||||
if got.TaskID != "task-participants" {
|
||||
t.Fatalf("TaskID = %q, want projected task id", got.TaskID)
|
||||
}
|
||||
if !got.DecisionRequired {
|
||||
t.Fatalf("DecisionRequired = false, want per-task row to win: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ func (s *Service) RegisterEventSubscribers(eb bus.EventBus) error {
|
||||
subject string
|
||||
handler bus.EventHandler
|
||||
}{
|
||||
{events.TaskCreated, s.handleTaskCreated},
|
||||
{events.TaskUpdated, s.handleTaskUpdated},
|
||||
{events.TaskMoved, s.handleTaskMoved},
|
||||
{events.OfficeApprovalResolved, s.handleApprovalResolved},
|
||||
@@ -644,15 +645,48 @@ func (s *Service) projectIDForTask(ctx context.Context, taskID string) string {
|
||||
return projectID
|
||||
}
|
||||
|
||||
// handleTaskCreated fires a task_assigned run when a newly-created task
|
||||
// already has a runner. The task.created payload may not carry the
|
||||
// projected assignee, so this falls back to the stored runner row.
|
||||
func (s *Service) handleTaskCreated(ctx context.Context, event *bus.Event) error {
|
||||
data, err := decodeEventData[TaskUpdatedData](event)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return s.queueTaskAssignedRun(ctx, data.TaskID, data.AssigneeAgentProfileID, true)
|
||||
}
|
||||
|
||||
// handleTaskUpdated fires a task_assigned run when an agent is assigned.
|
||||
func (s *Service) handleTaskUpdated(ctx context.Context, event *bus.Event) error {
|
||||
data, err := decodeEventData[TaskUpdatedData](event)
|
||||
if err != nil || data.AssigneeAgentProfileID == "" {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
payload := mustJSON(map[string]string{"task_id": data.TaskID})
|
||||
key := fmt.Sprintf("task_assigned:%s", data.TaskID)
|
||||
return s.QueueRun(ctx, data.AssigneeAgentProfileID, RunReasonTaskAssigned, payload, key)
|
||||
return s.queueTaskAssignedRun(ctx, data.TaskID, data.AssigneeAgentProfileID, false)
|
||||
}
|
||||
|
||||
func (s *Service) queueTaskAssignedRun(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
agentProfileID string,
|
||||
fallbackToStoredRunner bool,
|
||||
) error {
|
||||
if taskID == "" {
|
||||
return nil
|
||||
}
|
||||
if agentProfileID == "" && fallbackToStoredRunner {
|
||||
fields, err := s.repo.GetTaskExecutionFields(ctx, taskID)
|
||||
if err != nil || fields == nil {
|
||||
return nil
|
||||
}
|
||||
agentProfileID = fields.AssigneeAgentProfileID
|
||||
}
|
||||
if agentProfileID == "" {
|
||||
return nil
|
||||
}
|
||||
payload := mustJSON(map[string]string{"task_id": taskID})
|
||||
key := fmt.Sprintf("task_assigned:%s:%s", taskID, agentProfileID)
|
||||
return s.QueueRun(ctx, agentProfileID, RunReasonTaskAssigned, payload, key)
|
||||
}
|
||||
|
||||
// handleTaskMoved logs the step change to the activity log and queues
|
||||
|
||||
@@ -221,6 +221,98 @@ func TestCommentCreated_WakesAssignee(t *testing.T) {
|
||||
t.Fatal("expected task_comment run for assigned worker")
|
||||
}
|
||||
|
||||
func TestTaskCreated_WakesAssigneeFromStoredRunner(t *testing.T) {
|
||||
svc, eb := newTestServiceWithBus(t)
|
||||
ctx := context.Background()
|
||||
|
||||
createTestAgent(t, svc, "ws-1", "worker-created")
|
||||
insertTestTask(t, svc, "task-created-assigned", "ws-1")
|
||||
setTestTaskAssignee(t, svc, "task-created-assigned", "worker-created")
|
||||
|
||||
event := bus.NewEvent(events.TaskCreated, "test", map[string]string{
|
||||
"task_id": "task-created-assigned",
|
||||
})
|
||||
if err := eb.Publish(ctx, events.TaskCreated, event); err != nil {
|
||||
t.Fatalf("publish task created event: %v", err)
|
||||
}
|
||||
|
||||
runs, err := svc.ListRuns(ctx, "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list runs: %v", err)
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.AgentProfileID == "worker-created" && run.Reason == service.RunReasonTaskAssigned {
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal([]byte(run.Payload), &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if payload["task_id"] != "task-created-assigned" {
|
||||
t.Fatalf("payload = %#v, want created task ID", payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected task_assigned run for worker-created, got %#v", runs)
|
||||
}
|
||||
|
||||
func TestTaskCreated_NoopWhenNoStoredRunner(t *testing.T) {
|
||||
svc, eb := newTestServiceWithBus(t)
|
||||
ctx := context.Background()
|
||||
|
||||
insertTestTask(t, svc, "task-created-unassigned", "ws-1")
|
||||
|
||||
event := bus.NewEvent(events.TaskCreated, "test", map[string]string{
|
||||
"task_id": "task-created-unassigned",
|
||||
})
|
||||
if err := eb.Publish(ctx, events.TaskCreated, event); err != nil {
|
||||
t.Fatalf("publish task created event: %v", err)
|
||||
}
|
||||
|
||||
runs, err := svc.ListRuns(ctx, "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list runs: %v", err)
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.Reason == service.RunReasonTaskAssigned {
|
||||
t.Fatalf("task.created without runner queued task_assigned run: %#v", run)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskAssigned_ReassignmentUsesAgentScopedIdempotency(t *testing.T) {
|
||||
svc, eb := newTestServiceWithBus(t)
|
||||
ctx := context.Background()
|
||||
|
||||
createTestAgent(t, svc, "ws-1", "worker-old")
|
||||
createTestAgent(t, svc, "ws-1", "worker-new")
|
||||
insertTestTask(t, svc, "task-reassigned", "ws-1")
|
||||
|
||||
for _, agentID := range []string{"worker-old", "worker-new"} {
|
||||
event := bus.NewEvent(events.TaskUpdated, "test", map[string]string{
|
||||
"task_id": "task-reassigned",
|
||||
"assignee_agent_profile_id": agentID,
|
||||
})
|
||||
if err := eb.Publish(ctx, events.TaskUpdated, event); err != nil {
|
||||
t.Fatalf("publish task updated event for %s: %v", agentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
runs, err := svc.ListRuns(ctx, "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list runs: %v", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, run := range runs {
|
||||
if run.Reason == service.RunReasonTaskAssigned &&
|
||||
(run.AgentProfileID == "worker-old" || run.AgentProfileID == "worker-new") {
|
||||
seen[run.AgentProfileID] = true
|
||||
}
|
||||
}
|
||||
if !seen["worker-old"] || !seen["worker-new"] {
|
||||
t.Fatalf("task assignment runs = %#v, want both old and new assignees", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentCreated_SkipsSelfComment(t *testing.T) {
|
||||
svc, eb := newTestServiceWithBus(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -711,9 +711,10 @@ func (si *SchedulerIntegration) checkIdleSkip(
|
||||
// task-level execution_policy override was retired in Phase 4 of
|
||||
// task-model-unification.
|
||||
func (si *SchedulerIntegration) resolveExecutorForRun(
|
||||
ctx context.Context, agent *models.AgentInstance, _ string,
|
||||
ctx context.Context, agent *models.AgentInstance, payload string,
|
||||
) (*ExecutorConfig, error) {
|
||||
return si.svc.ResolveExecutor(ctx, "", agent.ID, "", "")
|
||||
projectID := si.extractProjectID(ctx, payload)
|
||||
return si.svc.ResolveExecutor(ctx, "", agent.ID, projectID, "")
|
||||
}
|
||||
|
||||
// buildPromptContext assembles a PromptContext from run data.
|
||||
|
||||
@@ -2,6 +2,7 @@ package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -53,6 +54,73 @@ func TestSchedulerIntegration_TickProcessesRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerIntegration_ResolvesExecutorFromTaskProject(t *testing.T) {
|
||||
mock := &mockTaskStarter{}
|
||||
svc := newTestService(t, service.ServiceOptions{TaskStarter: mock})
|
||||
ctx := context.Background()
|
||||
|
||||
agent := makeAgent("worker-project-exec", models.AgentRoleWorker)
|
||||
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
project := &models.Project{
|
||||
WorkspaceID: "ws-1",
|
||||
Name: "Project Executor",
|
||||
ExecutorConfig: `{"type":"local_pc"}`,
|
||||
}
|
||||
if err := svc.CreateProject(ctx, project); err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
svc.ExecSQL(t, `INSERT INTO tasks
|
||||
(id, workspace_id, project_id, title, description, created_at, updated_at)
|
||||
VALUES ('task-project-exec', 'ws-1', ?, 'Project executor task', 'desc',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, project.ID)
|
||||
|
||||
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned,
|
||||
`{"task_id":"task-project-exec"}`, ""); err != nil {
|
||||
t.Fatalf("queue run: %v", err)
|
||||
}
|
||||
|
||||
service.RunSchedulerTick(svc, ctx)
|
||||
|
||||
if mock.callCount() != 1 {
|
||||
t.Fatalf("StartTask calls = %d, want 1", mock.callCount())
|
||||
}
|
||||
|
||||
runs, err := svc.ListRuns(ctx, "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list runs: %v", err)
|
||||
}
|
||||
var runID string
|
||||
for _, run := range runs {
|
||||
if run.AgentProfileID == agent.ID && run.Reason == service.RunReasonTaskAssigned {
|
||||
runID = run.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if runID == "" {
|
||||
t.Fatalf("missing task_assigned run for %s: %#v", agent.ID, runs)
|
||||
}
|
||||
events, err := svc.ListRunEventsForTest(ctx, runID)
|
||||
if err != nil {
|
||||
t.Fatalf("list run events: %v", err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.EventType != "adapter.invoke" {
|
||||
continue
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
|
||||
t.Fatalf("unmarshal adapter event payload: %v", err)
|
||||
}
|
||||
if payload["executor_type"] != "local_pc" {
|
||||
t.Fatalf("executor_type = %v, want local_pc", payload["executor_type"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing adapter.invoke event for run %s: %#v", runID, events)
|
||||
}
|
||||
|
||||
func TestSchedulerIntegration_PausedAgentSkipped(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -64,6 +64,8 @@ The executor is resolved automatically when the scheduler launches a session for
|
||||
|
||||
`executor_preference` shape mirrors project executor config: `{ type, image, resource_limits, environment_id }`.
|
||||
|
||||
When an agent creates another agent and omits `executor_preference`, the new agent inherits the creator's executor preference before defaults are applied. This keeps delegated child agents launchable in the same executor context unless the creator explicitly overrides the preference.
|
||||
|
||||
Worktrees are automatic: when a task targets a repository, the system creates a git worktree (branch) using the existing `worktree.Manager`. Strategy (per-task or shared) comes from the project config.
|
||||
|
||||
## Data model
|
||||
|
||||
@@ -31,7 +31,7 @@ Inbox is not a table. It is a read-side aggregation over:
|
||||
- **Pending approvals**: hire requests, budget increase requests, board approval requests.
|
||||
- **Budget alerts**: agents approaching or exceeding limits.
|
||||
- **Agent errors**: sessions that failed with unrecoverable errors.
|
||||
- **Review requests**: tasks in `in_review` status.
|
||||
- **Review requests**: tasks with pending reviewer/approver decisions.
|
||||
- **Clarification requests**: agents waiting on a question response.
|
||||
|
||||
Items ordered by recency, unresolved first.
|
||||
@@ -200,7 +200,7 @@ The inbox listing itself is a **computed view** — `office_inbox` does not exis
|
||||
- **Budget alerts** and **agent errors** surfaced in the inbox are activity rows with `action ∈ {budget.alert, agent.error}` — the inbox re-reads up to 20 most-recent entries per type on every fetch. They do not have a separate "resolved" state; the rows persist forever in the log.
|
||||
- **Failed-run and auto-paused-agent rows** come from `FailureInboxSource` (failed `office_runs` rows + `office_agents.status = paused`). They persist across restart and disappear from the inbox only when the user "Mark fixed"-dismisses them, when the run is re-queued, or when the agent is un-paused.
|
||||
- **Inbox dismissals** persist in `office_inbox_dismissals` keyed by `(user_id, item_kind, item_id)`. Single-user kandev writes `user_id = "default"`. The inbox filters dismissed rows out on every fetch; the underlying activity/run rows are NOT deleted.
|
||||
- **Reviewer/approver participants** for task `execution_policy` stages persist in `workflow_step_participants`; their decisions persist in `workflow_step_decisions`. Decisions are superseded (never deleted) when re-recorded, so the "viewer needs decision" check is deterministic across restarts.
|
||||
- **Reviewer/approver participants** for task `execution_policy` stages persist in `workflow_step_participants`; only rows with `decision_required = true` produce `task_review_request` inbox items. Runner/assignee rows never produce review inbox items. Decisions persist in `workflow_step_decisions` and are superseded (never deleted) when re-recorded, so the "viewer needs decision" check is deterministic across restarts.
|
||||
- **Approval `task_blockers`** rows (in `task_blockers`) persist; blocker resolution on completion triggers a `task_blockers_resolved` wakeup queued in the office scheduler queue, which itself persists in `office_run_requests`.
|
||||
- **Notification subscriptions** persist in `notification_subscriptions` (event type `office.inbox_item`). User preference about which providers receive inbox notifications survives restart.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ A SQLite-persisted queue of "wake this agent up" requests. Every periodic, event
|
||||
| `agent_error` | A sub-agent's session failed (escalation to coordinator) | `{agent_profile_id, run_id, error}` |
|
||||
| `self` | Agent self-wake via tool call | `{reason, payload?}` |
|
||||
| `user` | User mention / explicit wake from the UI | `{user_id, context?}` |
|
||||
| `task_assigned` | Task's `assignee_agent_instance_id` is set or changed | `{task_id}` |
|
||||
| `task_assigned` | Task's `assignee_agent_instance_id` is set or changed, including a newly-created task/subtask that already has a runner | `{task_id}` |
|
||||
| `task_blockers_resolved` | All blocking tasks of an assigned task reach `done` | `{task_id, resolved_blocker_ids}` |
|
||||
| `task_children_completed` | All child tasks of an assigned task reach terminal state | `{task_id}` |
|
||||
| `approval_resolved` | An approval requested by this agent is approved/rejected | `{approval_id, status, decision_note}` |
|
||||
@@ -58,7 +58,7 @@ Coalescing happens entirely at the wakeup-request layer; the runs table is the e
|
||||
1. **Source-level dedup via `idempotency_key`** (UNIQUE column). Format:
|
||||
- `routine:<routine_id>:<trigger_id>:<unix_minute>` for cron routines.
|
||||
- `comment:<comment_id>` for comments.
|
||||
- `task_assigned:<task_id>:<timestamp>` for assignment events.
|
||||
- `task_assigned:<task_id>:<agent_instance_id>` for task creation/assignment events.
|
||||
Duplicate inserts in the same window are rejected silently. Handles webhook re-delivery, event-bus replay, and restart recovery.
|
||||
|
||||
2. **Claim-time merge.** When the dispatcher processes a wakeup-request, it looks for an in-flight run for the same agent (`queued` -> `scheduled-retry` -> `running`, in that order). If one exists: insert the new request with `status="coalesced"`, `run_id=<existing>`, merge the new request's payload into the existing run's `context_snapshot`, and increment `coalesced_count` on the in-flight wakeup-request. The agent sees the merged context when it actually runs. If none exists: insert the wakeup-request with `status="queued"` and create the corresponding `runs` row.
|
||||
@@ -178,6 +178,10 @@ WHERE assignee_agent_instance_id = $agentID
|
||||
AND archived_at IS NULL
|
||||
```
|
||||
|
||||
### Executor resolution at launch
|
||||
|
||||
When the scheduler claims a run, it resolves the executor using the agent preference first. If the agent has no executor preference and the run payload carries `task_id`, the scheduler resolves the task's project and allows that project executor config to satisfy the launch. This prevents assigned task runs from retrying indefinitely solely because the worker's agent row has an empty executor preference.
|
||||
|
||||
### Staleness check (before claim)
|
||||
|
||||
Before `processWakeup` proceeds past the agent status guard, the scheduler checks whether the wakeup's context is still valid. This runs on every wakeup that carries a `task_id` in its payload.
|
||||
|
||||
@@ -47,7 +47,7 @@ This spec consolidates the office task surface: lifecycle, parent/child handoffs
|
||||
- Transitioning `in_review → todo|in_progress` (rework) supersedes all prior decisions; re-entering `in_review` starts a fresh round.
|
||||
- Approving a task while approvers are pending wakes the assignee with `task_changes_requested` (when changes are requested) or `task_ready_to_close` (when the final approval lands).
|
||||
- Recording a decision does NOT terminate the deciding agent's session. The reviewer's session stays IDLE between cycles and is re-used on a subsequent `task_review_requested` wakeup.
|
||||
- Tasks awaiting the current user's review/approval surface in the inbox as item type `task_review_request`.
|
||||
- Tasks awaiting the current user's review/approval surface in the inbox as item type `task_review_request` only when the user/agent is a reviewer or approver participant with a required decision. Runner/assignee participation alone does not create a review inbox item.
|
||||
- v1 approval is **unanimous**: every listed approver must approve. There is no quorum / any-of-N mode.
|
||||
|
||||
### D. Inline editable properties
|
||||
@@ -69,6 +69,7 @@ A backend pipeline runs synchronously on every relevant task property change. Pr
|
||||
- `status: blocked → unblocked`: wake assignee with `{reason: "task_unblocked"}`.
|
||||
- `status: done|cancelled → todo|in_progress`: wake assignee with `{reason: "task_reopened", actor_id, actor_type}`.
|
||||
- **Assignee change**: wake new assignee with `{reason: "assigned", comment_id?, actor_id}`. If old assignee had an active session, cancel it cleanly (`session.cancelled` notification) and surface that to the new assignee's context.
|
||||
- **Created task with assignee**: if a newly-created task or subtask already has a runner, queue the same assigned wakeup idempotently with `{reason: "assigned", task_id}`.
|
||||
- **Comment created by user**: wake assignee with `{reason: "user_comment", comment_id}`. Wake any @-mentioned agents in the same workspace with `{reason: "mentioned", comment_id}`.
|
||||
- `status → in_review`: wake every reviewer and approver with `{reason: "task_review_requested", role}`.
|
||||
- `decision = changes_requested`: wake assignee with `{reason: "task_changes_requested", comment}`.
|
||||
|
||||
Reference in New Issue
Block a user