From 84a2bf27bc2a76e4b472148fd851c10804760d24 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Tue, 28 Jul 2026 17:29:53 -0400 Subject: [PATCH] feat: route interactive work by whether a person is watching (attended) nextTask's hard `interactive` skip asked `actor.type !== "human"`, which conflates two different questions: "is this a person" and "is a person watching this session". An interactive Claude session is type=agent and human-attended, and it is exactly the caller `interactive` work exists for -- SYD-239 introduced the preference precisely because headless workers strand on it. So the top of the curated queue was invisible to the only non-human caller able to act on it: next_task handed this session rank 1600 while ranks 300-700 sat unworked. Splitting claude/dev into claude/dev (dispatch) and claude/interactive was necessary but not sufficient -- both are type=agent, so the filter could not tell them apart. actors.attended states the property instead of inferring it, and isAttendedCaller() is the single place that answers it, beside callerClassification() which answers the engine question. Deliberately not the alternative: treating a `*/interactive` name suffix as attended needs no migration, but makes an actor's NAME load-bearing for a routing decision, which is the read-meaning-out-of-a-string pattern this codebase keeps paying for (SYD-280 exists to delete it for PR attribution). Not a privilege. attended gates routing only -- requireHuman never reads it, so flipping it on cannot let an agent take a human-only action. It can only stop nextTask withholding work from a session that can finish it. Setting it is still human-only, because a caller must not widen its own queue, and it is refused on humans, who are attended by definition: accepting a flag that changes nothing would imply it could be turned off. Absent reads as false, so an Actor projection that forgets the column withholds work rather than handing a headless worker something it cannot finish. Making the field required rather than optional on the Actor type was deliberate -- the compiler then found all nine construction sites, four of which had a real row to project the true value from. No backfill: isAttendedCaller short-circuits on type === "human", so existing humans need no migration, and the one agent that should be attended is a decision rather than something derivable from the data. --- drizzle/0019_boring_pet_avengers.sql | 1 + drizzle/meta/0019_snapshot.json | 1874 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/cli.ts | 6 +- src/db/schema.ts | 7 + src/rest/api-routes.ts | 13 + src/rest/schemas.ts | 2 + src/services/actors.ts | 58 +- src/services/auth.ts | 4 +- src/services/dependencies.ts | 10 +- src/services/linear-import.ts | 2 +- src/services/supervised-sessions.ts | 4 +- src/services/worker-preference.ts | 20 + tests/mcp/read-tools.test.ts | 14 +- tests/rest/api-me.test.ts | 8 +- tests/services/actors.test.ts | 51 + tests/services/queue.test.ts | 31 + 17 files changed, 2091 insertions(+), 21 deletions(-) create mode 100644 drizzle/0019_boring_pet_avengers.sql create mode 100644 drizzle/meta/0019_snapshot.json diff --git a/drizzle/0019_boring_pet_avengers.sql b/drizzle/0019_boring_pet_avengers.sql new file mode 100644 index 00000000..ce076ca4 --- /dev/null +++ b/drizzle/0019_boring_pet_avengers.sql @@ -0,0 +1 @@ +ALTER TABLE `actors` ADD `attended` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 00000000..c8697aa0 --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1874 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1de348bf-c65f-4f20-8d49-7b9ba9c34f00", + "prevId": "c536c6b8-069d-48b7-b072-df407c855be8", + "tables": { + "actors": { + "name": "actors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attended": { + "name": "attended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "actors_name_unique": { + "name": "actors_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "affirmation_keys": { + "name": "affirmation_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "affirmation_keys_active_uniq": { + "name": "affirmation_keys_active_uniq", + "columns": [ + "actor_id", + "public_key" + ], + "isUnique": true, + "where": "revoked_at is null" + } + }, + "foreignKeys": { + "affirmation_keys_actor_id_actors_id_fk": { + "name": "affirmation_keys_actor_id_actors_id_fk", + "tableFrom": "affirmation_keys", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_sessions": { + "name": "agent_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agent_sessions_issue_id_idx": { + "name": "agent_sessions_issue_id_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_sessions_issue_id_issues_id_fk": { + "name": "agent_sessions_issue_id_issues_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_sessions_actor_id_actors_id_fk": { + "name": "agent_sessions_actor_id_actors_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_issue_id_issues_id_fk": { + "name": "attachments_issue_id_issues_id_fk", + "tableFrom": "attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "attachments_actor_id_actors_id_fk": { + "name": "attachments_actor_id_actors_id_fk", + "tableFrom": "attachments", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claim_lease_cutover": { + "name": "claim_lease_cutover", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claim_leases": { + "name": "claim_leases", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_beat_at": { + "name": "last_beat_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invalidated_at": { + "name": "invalidated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "claim_leases_token_hash_unique": { + "name": "claim_leases_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "claim_leases_issue_id_idx": { + "name": "claim_leases_issue_id_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "claim_leases_issue_id_issues_id_fk": { + "name": "claim_leases_issue_id_issues_id_fk", + "tableFrom": "claim_leases", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "claim_leases_actor_id_actors_id_fk": { + "name": "claim_leases_actor_id_actors_id_fk", + "tableFrom": "claim_leases", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delivery_attempts": { + "name": "delivery_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_ref": { + "name": "issue_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "derived_head_sha": { + "name": "derived_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_id": { + "name": "authorization_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "delivery_attempts_authorization_id_idx": { + "name": "delivery_attempts_authorization_id_idx", + "columns": [ + "authorization_id" + ], + "isUnique": false + }, + "delivery_attempts_issue_ref_idx": { + "name": "delivery_attempts_issue_ref_idx", + "columns": [ + "issue_ref" + ], + "isUnique": false + } + }, + "foreignKeys": { + "delivery_attempts_authorization_id_events_id_fk": { + "name": "delivery_attempts_authorization_id_events_id_fk", + "tableFrom": "delivery_attempts", + "tableTo": "events", + "columnsFrom": [ + "authorization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delivery_rollout": { + "name": "delivery_rollout", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependencies": { + "name": "dependencies", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blocked_id": { + "name": "blocked_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dependencies_blocker_id_issues_id_fk": { + "name": "dependencies_blocker_id_issues_id_fk", + "tableFrom": "dependencies", + "tableTo": "issues", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dependencies_blocked_id_issues_id_fk": { + "name": "dependencies_blocked_id_issues_id_fk", + "tableFrom": "dependencies", + "tableTo": "issues", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dependencies_blocker_id_blocked_id_pk": { + "columns": [ + "blocker_id", + "blocked_id" + ], + "name": "dependencies_blocker_id_blocked_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "via_agent_id": { + "name": "via_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "events_issue_id_idx": { + "name": "events_issue_id_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_issue_id_issues_id_fk": { + "name": "events_issue_id_issues_id_fk", + "tableFrom": "events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_actor_id_actors_id_fk": { + "name": "events_actor_id_actors_id_fk", + "tableFrom": "events", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_via_agent_id_actors_id_fk": { + "name": "events_via_agent_id_actors_id_fk", + "tableFrom": "events", + "tableTo": "actors", + "columnsFrom": [ + "via_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_session_id_sessions_id_fk": { + "name": "events_session_id_sessions_id_fk", + "tableFrom": "events", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "github_repos": { + "name": "github_repos", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "github_repos_full_name_unique": { + "name": "github_repos_full_name_unique", + "columns": [ + "full_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "github_repos_project_id_projects_id_fk": { + "name": "github_repos_project_id_projects_id_fk", + "tableFrom": "github_repos", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "issues": { + "name": "issues", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_detail": { + "name": "source_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "needs_input": { + "name": "needs_input", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worker_preference": { + "name": "worker_preference", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_rank": { + "name": "queue_rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "issues_project_id_idx": { + "name": "issues_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "issues_status_idx": { + "name": "issues_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "issues_assignee_id_idx": { + "name": "issues_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "issues_queue_rank_idx": { + "name": "issues_queue_rank_idx", + "columns": [ + "queue_rank" + ], + "isUnique": false + } + }, + "foreignKeys": { + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_id_actors_id_fk": { + "name": "issues_assignee_id_actors_id_fk", + "tableFrom": "issues", + "tableTo": "actors", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_creator_id_actors_id_fk": { + "name": "issues_creator_id_actors_id_fk", + "tableFrom": "issues", + "tableTo": "actors", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_links": { + "name": "login_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "login_links_token_hash_unique": { + "name": "login_links_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "login_links_actor_id_actors_id_fk": { + "name": "login_links_actor_id_actors_id_fk", + "tableFrom": "login_links", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_actions": { + "name": "pending_actions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "affirmed_by_id": { + "name": "affirmed_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affirmed_at": { + "name": "affirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_actions_active_uniq": { + "name": "pending_actions_active_uniq", + "columns": [ + "session_id", + "issue_id", + "action_type" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "pending_actions_session_id_sessions_id_fk": { + "name": "pending_actions_session_id_sessions_id_fk", + "tableFrom": "pending_actions", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pending_actions_issue_id_issues_id_fk": { + "name": "pending_actions_issue_id_issues_id_fk", + "tableFrom": "pending_actions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pending_actions_affirmed_by_id_actors_id_fk": { + "name": "pending_actions_affirmed_by_id_actors_id_fk", + "tableFrom": "pending_actions", + "tableTo": "actors", + "columnsFrom": [ + "affirmed_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pr_links": { + "name": "pr_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_by": { + "name": "declared_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_at": { + "name": "declared_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "confirmed_by": { + "name": "confirmed_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "pr_links_live_idx": { + "name": "pr_links_live_idx", + "columns": [ + "issue_id", + "repo", + "pr_number" + ], + "isUnique": true, + "where": "\"pr_links\".\"revoked_at\" IS NULL" + }, + "pr_links_pr_idx": { + "name": "pr_links_pr_idx", + "columns": [ + "repo", + "pr_number" + ], + "isUnique": false + }, + "pr_links_issue_idx": { + "name": "pr_links_issue_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pr_links_issue_id_issues_id_fk": { + "name": "pr_links_issue_id_issues_id_fk", + "tableFrom": "pr_links", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pr_links_declared_by_actors_id_fk": { + "name": "pr_links_declared_by_actors_id_fk", + "tableFrom": "pr_links", + "tableTo": "actors", + "columnsFrom": [ + "declared_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pr_links_confirmed_by_actors_id_fk": { + "name": "pr_links_confirmed_by_actors_id_fk", + "tableFrom": "pr_links", + "tableTo": "actors", + "columnsFrom": [ + "confirmed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pr_state": { + "name": "pr_state", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_ref": { + "name": "issue_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gh_updated_at": { + "name": "gh_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_transition_event_id": { + "name": "last_transition_event_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "pr_state_issue_ref_idx": { + "name": "pr_state_issue_ref_idx", + "columns": [ + "issue_ref" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pr_state_repo_pr_number_pk": { + "columns": [ + "repo", + "pr_number" + ], + "name": "pr_state_repo_pr_number_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_issue_number": { + "name": "next_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "projects_key_unique": { + "name": "projects_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'plain'" + }, + "via_agent_id": { + "name": "via_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_actor_id_actors_id_fk": { + "name": "sessions_actor_id_actors_id_fk", + "tableFrom": "sessions", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_via_agent_id_actors_id_fk": { + "name": "sessions_via_agent_id_actors_id_fk", + "tableFrom": "sessions", + "tableTo": "actors", + "columnsFrom": [ + "via_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_by_actor_id": { + "name": "updated_by_actor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_updated_by_actor_id_actors_id_fk": { + "name": "settings_updated_by_actor_id_actors_id_fk", + "tableFrom": "settings", + "tableTo": "actors", + "columnsFrom": [ + "updated_by_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_cursor": { + "name": "webhook_cursor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_event_id": { + "name": "last_event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_project_id_projects_id_fk": { + "name": "webhooks_project_id_projects_id_fk", + "tableFrom": "webhooks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 46bf3ff2..bdc45015 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1785255364174, "tag": "0018_awesome_randall", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1785273879961, + "tag": "0019_boring_pet_avengers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index 7dbf4417..7c4acc83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { SwitchyardError } from "./services/errors.js"; // The CLI operates directly on the db file with no HTTP auth, so it stands // in for a human operator when calling human-only service functions. -const cliActor: Actor = { id: 0, name: "cli", type: "human" }; +const cliActor: Actor = { id: 0, name: "cli", type: "human", attended: true }; // Resolves a human actor by name, matching mint-supervised-session's inline // lookup below — factored out because the affirm-key commands need it three @@ -32,7 +32,7 @@ function requireHumanActor(db: ReturnType, name: string): Actor { `"${name}" is an actor of type "${row.type}", not a human — affirmation keys belong to humans.`, ); } - return { id: row.id, name: row.name, type: row.type }; + return { id: row.id, name: row.name, type: row.type, attended: row.attended }; } const [dbPath, cmd, ...args] = process.argv.slice(2); @@ -100,7 +100,7 @@ try { `"${humanName}" is an actor of type "${row.type}", not a human — supervised sessions root on a human actor.`, ); } - const human: Actor = { id: row.id, name: row.name, type: row.type }; + const human: Actor = { id: row.id, name: row.name, type: row.type, attended: row.attended }; const { sessionToken } = openSupervisedSession(db, human, agentName); console.log(`supervised session token (shown once): ${sessionToken}`); console.log( diff --git a/src/db/schema.ts b/src/db/schema.ts index 6f857657..61e985ff 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -29,6 +29,13 @@ export const actors = sqliteTable("actors", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), type: text("type", { enum: ["human", "agent", "service"] }).notNull(), + // Is a person watching this caller's session? Distinct from `type`, which + // answers "is this a person". An interactive Claude session is type=agent + // but human-attended, and `worker_preference: "interactive"` work is meant + // for exactly that caller — nextTask used to route it away because it could + // only ask the first question. Routing only; it grants no authority and + // requireHuman never consults it. + attended: integer("attended", { mode: "boolean" }).notNull().default(false), tokenHash: text("token_hash"), createdAt: integer("created_at").notNull().default(now()), }); diff --git a/src/rest/api-routes.ts b/src/rest/api-routes.ts index e3890d25..7e86760d 100644 --- a/src/rest/api-routes.ts +++ b/src/rest/api-routes.ts @@ -9,6 +9,7 @@ import { createActor, getActorById, listActorsWithStatus, + setActorAttended, rotateActorToken, revokeActorToken, type Actor, @@ -121,6 +122,7 @@ import { duplicateBody, settingPutBody, redeliverBody, + actorAttendedBody, resolveDeliveryBody, resolveDeviationBody, deliveryAttemptStartBody, @@ -204,6 +206,17 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen return id; }; + app.post("/actors/:id/attended", body(actorAttendedBody), (c) => + c.json( + setActorAttended( + db, + c.var.actor, + parseActorId(c.req.param("id")), + c.req.valid("json").attended, + ), + ), + ); + app.post("/actors/:id/rotate-token", (c) => c.json(rotateActorToken(db, c.var.actor, parseActorId(c.req.param("id")))), ); diff --git a/src/rest/schemas.ts b/src/rest/schemas.ts index b3c39d25..93c4426c 100644 --- a/src/rest/schemas.ts +++ b/src/rest/schemas.ts @@ -46,6 +46,8 @@ export const issueUpdateBody = z.object({ export const redeliverBody = z.object({ expectedHeadSha: z.string().min(1).optional() }); +export const actorAttendedBody = z.object({ attended: z.boolean() }); + export const resolveDeliveryBody = z.object({ note: z.string().min(1) }); // SYD-262: `reason` is validated against RESOLVABLE_DEVIATIONS in the service, diff --git a/src/services/actors.ts b/src/services/actors.ts index 238fdad3..cf48495e 100644 --- a/src/services/actors.ts +++ b/src/services/actors.ts @@ -5,7 +5,8 @@ import { SwitchyardError } from "./errors.js"; import { hashToken, mintToken } from "./tokens.js"; export type ActorType = "human" | "agent" | "service"; -export type Actor = { id: number; name: string; type: ActorType }; +/** `attended` = a person is watching this caller (see actors.attended). */ +export type Actor = { id: number; name: string; type: ActorType; attended: boolean }; export type ActorWithStatus = Actor & { createdAt: number; hasToken: boolean }; function requireHuman(actor: Actor, action: string): void { @@ -16,7 +17,7 @@ function requireHuman(actor: Actor, action: string): void { export function createActor( db: Db, - input: { name: string; type: ActorType }, + input: { name: string; type: ActorType; attended?: boolean }, ): { actor: Actor; token: string } { const existing = db.select().from(actors).where(eq(actors.name, input.name)).get(); if (existing) { @@ -27,10 +28,19 @@ export function createActor( const token = mintToken("syd"); const row = db .insert(actors) - .values({ name: input.name, type: input.type, tokenHash: hashToken(token) }) + .values({ + name: input.name, + type: input.type, + // A human IS the attended caller, so it needs no flag. Anything else + // defaults to unattended: the flag only ever WITHHOLDS work, so failing + // closed costs a routing miss rather than handing a headless worker + // something it cannot finish. + attended: input.attended ?? input.type === "human", + tokenHash: hashToken(token), + }) .returning() .get(); - return { actor: { id: row.id, name: row.name, type: row.type }, token }; + return { actor: { id: row.id, name: row.name, type: row.type, attended: row.attended }, token }; } export function authenticate(db: Db, token: string): Actor | null { @@ -39,7 +49,7 @@ export function authenticate(db: Db, token: string): Actor | null { .from(actors) .where(eq(actors.tokenHash, hashToken(token))) .get(); - return row ? { id: row.id, name: row.name, type: row.type } : null; + return row ? { id: row.id, name: row.name, type: row.type, attended: row.attended } : null; } /** @@ -49,15 +59,21 @@ export function authenticate(db: Db, token: string): Actor | null { */ export function getOrCreateActor(db: Db, name: string, type: ActorType): Actor { const existing = db.select().from(actors).where(eq(actors.name, name)).get(); - if (existing) return { id: existing.id, name: existing.name, type: existing.type }; + if (existing) + return { + id: existing.id, + name: existing.name, + type: existing.type, + attended: existing.attended, + }; const row = db.insert(actors).values({ name, type }).returning().get(); - return { id: row.id, name: row.name, type: row.type }; + return { id: row.id, name: row.name, type: row.type, attended: row.attended }; } export function getActorById(db: Db, id: number): Actor { const row = db.select().from(actors).where(eq(actors.id, id)).get(); if (!row) throw new SwitchyardError(`There is no actor with id ${id}.`); - return { id: row.id, name: row.name, type: row.type }; + return { id: row.id, name: row.name, type: row.type, attended: row.attended }; } export function listActorsWithStatus(db: Db): ActorWithStatus[] { @@ -69,11 +85,37 @@ export function listActorsWithStatus(db: Db): ActorWithStatus[] { id: r.id, name: r.name, type: r.type, + attended: r.attended, createdAt: r.createdAt, hasToken: r.tokenHash !== null, })); } +/** + * Marks whether a person is watching this actor's sessions (see + * actors.attended). Human-only, because it decides which work `next_task` + * hands out and a caller must not be able to widen its own queue. + * + * Not a privilege: `attended` gates routing only, never authority — + * requireHuman never reads it, so flipping it on cannot let an agent take a + * human-only action. It only stops nextTask withholding `interactive` work + * from a session that can actually finish it. + * + * Refused for humans, who are attended by definition — silently accepting a + * flag that changes nothing would imply it could be turned off. + */ +export function setActorAttended(db: Db, actor: Actor, actorId: number, attended: boolean): Actor { + requireHuman(actor, "change whether an actor is attended"); + const target = getActorById(db, actorId); + if (target.type === "human") { + throw new SwitchyardError( + `${target.name} is a human and is attended by definition — the flag is for agent sessions a person is driving.`, + ); + } + db.update(actors).set({ attended }).where(eq(actors.id, actorId)).run(); + return { ...target, attended }; +} + /** Mints a fresh token for an existing actor, invalidating the old one. Human-only. */ export function rotateActorToken(db: Db, actor: Actor, actorId: number): { token: string } { requireHuman(actor, "rotate an actor's token"); diff --git a/src/services/auth.ts b/src/services/auth.ts index 6f7f0ad3..a8d2ed34 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -49,7 +49,7 @@ export function redeemLoginLink(db: Db, token: string): { sessionToken: string; if (!a) { throw new SwitchyardError(`Login link references a missing actor (id ${row.actorId}).`); } - return { sessionToken, actor: { id: a.id, name: a.name, type: a.type } }; + return { sessionToken, actor: { id: a.id, name: a.name, type: a.type, attended: a.attended } }; } // kind='plain' is a security boundary, not a convenience filter: the @@ -65,7 +65,7 @@ export function getSessionActor(db: Db, sessionToken: string): Actor | null { .where(and(eq(sessions.tokenHash, hashToken(sessionToken)), eq(sessions.kind, "plain"))) .get(); if (!row || row.s.expiresAt < nowSec()) return null; - return { id: row.a.id, name: row.a.name, type: row.a.type }; + return { id: row.a.id, name: row.a.name, type: row.a.type, attended: row.a.attended }; } // kind='plain' scoping mirrors getSessionActor above: a supervised session diff --git a/src/services/dependencies.ts b/src/services/dependencies.ts index 26fea6d3..426df400 100644 --- a/src/services/dependencies.ts +++ b/src/services/dependencies.ts @@ -12,7 +12,11 @@ import { listOpenPrByIssueId } from "./pr-status.js"; import { EXECUTABLE_GATE_ACTIONS, findOrCreatePendingAction, isHardGated } from "./hard-gate.js"; import { getSetting } from "./settings.js"; import { affinityRank, QUEUE_RANK_ORDER } from "./queue.js"; -import { callerClassification, INTERACTIVE_PREFERENCE } from "./worker-preference.js"; +import { + callerClassification, + isAttendedCaller, + INTERACTIVE_PREFERENCE, +} from "./worker-preference.js"; const CLOSED = ["done", "canceled"] as const; const PRIORITY_RANK = sql`CASE ${issues.priority} @@ -272,7 +276,9 @@ export function nextTask(db: Db, actor: Actor, projectKey?: string): IssueView | // skips it at dispatch — but nextTask ignored it entirely, so an agent asking // for work directly could still be handed one. Everything else about // worker_preference stays soft, sorted by affinityRank below. - if (actor.type !== "human") { + // Keyed on "is a person watching", not "is this a person" — an attended + // agent session is the caller this work is FOR (see isAttendedCaller). + if (!isAttendedCaller(actor)) { conditions.push(sql`(${issues.workerPreference} IS NULL OR ${issues.workerPreference} <> ${INTERACTIVE_PREFERENCE})`); } diff --git a/src/services/linear-import.ts b/src/services/linear-import.ts index 660ea6fa..0ba5bacd 100644 --- a/src/services/linear-import.ts +++ b/src/services/linear-import.ts @@ -349,7 +349,7 @@ export async function executeImportPlan( // The importer is a host-CLI, human-operated tool — same standing as // src/cli.ts's cliActor for human-only service calls (SYD-157 guard). - const importOperator: Actor = { id: 0, name: "cli", type: "human" }; + const importOperator: Actor = { id: 0, name: "cli", type: "human", attended: true }; for (const p of plan.projects) { if (!p.exists) { createProject(db, importOperator, { key: p.key, name: p.name }); diff --git a/src/services/supervised-sessions.ts b/src/services/supervised-sessions.ts index b2e70175..ac348108 100644 --- a/src/services/supervised-sessions.ts +++ b/src/services/supervised-sessions.ts @@ -69,8 +69,8 @@ export function resolveSupervisedPrincipal(db: Db, sessionToken: string): Princi if (!human || !agent || human.type !== "human" || agent.type !== "agent") return null; return { - actor: { id: human.id, name: human.name, type: human.type }, - viaAgent: { id: agent.id, name: agent.name, type: agent.type }, + actor: { id: human.id, name: human.name, type: human.type, attended: human.attended }, + viaAgent: { id: agent.id, name: agent.name, type: agent.type, attended: agent.attended }, sessionId: row.id, }; } diff --git a/src/services/worker-preference.ts b/src/services/worker-preference.ts index 154088b3..9ee71da4 100644 --- a/src/services/worker-preference.ts +++ b/src/services/worker-preference.ts @@ -30,3 +30,23 @@ export function callerClassification(actor: { name: string; type: string }): str if (actor.type === "human") return INTERACTIVE_PREFERENCE; return actor.name.split("/")[0]; } + +/** + * Whether a person is watching this caller — the question the `interactive` + * hard skip actually wants answered. + * + * It used to ask `actor.type !== "human"`, which conflates "is this a person" + * with "is a person watching". An interactive Claude session is type=agent and + * human-attended, and it is precisely the caller `interactive` work exists for; + * routing it away meant the top of the curated queue was invisible to the only + * non-human caller that could act on it. A human is attended by definition, so + * `attended` is set at creation for them and no caller has to remember. + * + * Reads as false when the flag is absent, so an Actor projection that forgot + * the column withholds work rather than handing a headless worker something it + * cannot finish. This is routing only — it grants no authority, and + * requireHuman never consults it. + */ +export function isAttendedCaller(actor: { type: string; attended?: boolean }): boolean { + return actor.type === "human" || actor.attended === true; +} diff --git a/tests/mcp/read-tools.test.ts b/tests/mcp/read-tools.test.ts index 82053baa..2604cc57 100644 --- a/tests/mcp/read-tools.test.ts +++ b/tests/mcp/read-tools.test.ts @@ -122,13 +122,23 @@ describe("MCP read tools", () => { it("whoami returns the actor the calling token authenticates as", async () => { const r = await client.callTool({ name: "whoami", arguments: {} }); - expect(JSON.parse(text(r))).toEqual({ id: agent.id, name: "claude/worker", type: "agent" }); + expect(JSON.parse(text(r))).toEqual({ + id: agent.id, + name: "claude/worker", + type: "agent", + attended: false, + }); }); it("whoami reflects a human actor's own token", async () => { const humanClient = await connect(human); const r = await humanClient.callTool({ name: "whoami", arguments: {} }); - expect(JSON.parse(text(r))).toEqual({ id: human.id, name: "sean", type: "human" }); + expect(JSON.parse(text(r))).toEqual({ + id: human.id, + name: "sean", + type: "human", + attended: true, + }); }); it("errors are agent-legible, not stack traces", async () => { diff --git a/tests/rest/api-me.test.ts b/tests/rest/api-me.test.ts index be15c19d..1c5b7f6a 100644 --- a/tests/rest/api-me.test.ts +++ b/tests/rest/api-me.test.ts @@ -10,7 +10,13 @@ describe("GET /me", () => { const app = buildApiRoutes(db); const res = await app.request("/me", { headers: { authorization: `Bearer ${token}` } }); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: 1, name: "claude/dev", type: "agent" }); + // attended is part of the identity a session should be able to read. + expect(await res.json()).toEqual({ + id: 1, + name: "claude/dev", + type: "agent", + attended: false, + }); expect((await app.request("/me")).status).toBe(401); }); }); diff --git a/tests/services/actors.test.ts b/tests/services/actors.test.ts index 0fea2cec..f68c6c88 100644 --- a/tests/services/actors.test.ts +++ b/tests/services/actors.test.ts @@ -6,6 +6,7 @@ import { listActorsWithStatus, rotateActorToken, revokeActorToken, + setActorAttended, } from "../../src/services/actors.js"; describe("actors", () => { @@ -93,4 +94,54 @@ describe("actors", () => { const human = createActor(db, { name: "sean", type: "human" }).actor; expect(() => revokeActorToken(db, human, 999)).toThrowError(/no actor with id 999/i); }); + + // `attended` answers "is a person watching this caller", which is the + // question nextTask's interactive skip actually wants — distinct from + // `type`, which answers "is this a person". + describe("attended", () => { + const setup = () => { + const db = openDb(":memory:"); + const human = createActor(db, { name: "sean", type: "human" }).actor; + const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; + return { db, human, agent }; + }; + + it("defaults humans to attended and everyone else to not", () => { + const { human, agent, db } = setup(); + expect(human.attended).toBe(true); + expect(agent.attended).toBe(false); + expect(createActor(db, { name: "svc", type: "service" }).actor.attended).toBe(false); + }); + + it("honours an explicit attended at creation", () => { + const { db } = setup(); + const s = createActor(db, { name: "claude/interactive", type: "agent", attended: true }); + expect(s.actor.attended).toBe(true); + expect(authenticate(db, s.token)?.attended).toBe(true); + }); + + it("lets a human set and clear it on an agent, and it survives a round-trip", () => { + const { db, human, agent } = setup(); + expect(setActorAttended(db, human, agent.id, true).attended).toBe(true); + expect(listActorsWithStatus(db).find((a) => a.id === agent.id)?.attended).toBe(true); + expect(setActorAttended(db, human, agent.id, false).attended).toBe(false); + expect(listActorsWithStatus(db).find((a) => a.id === agent.id)?.attended).toBe(false); + }); + + // A caller must not be able to widen its own queue. + it("refuses a non-human caller", () => { + const { db, agent } = setup(); + expect(() => setActorAttended(db, agent, agent.id, true)).toThrowError(/only humans/i); + }); + + it("refuses to set it on a human — attended by definition, so a flag would imply it can be off", () => { + const { db, human } = setup(); + expect(() => setActorAttended(db, human, human.id, false)).toThrowError(/by definition/i); + }); + + it("errors on an unknown actor id", () => { + const { db, human } = setup(); + expect(() => setActorAttended(db, human, 999, true)).toThrowError(/no actor with id 999/i); + }); + }); }); diff --git a/tests/services/queue.test.ts b/tests/services/queue.test.ts index 51afa404..d3dcc705 100644 --- a/tests/services/queue.test.ts +++ b/tests/services/queue.test.ts @@ -172,6 +172,37 @@ describe("nextTask skips what the caller cannot take (SYD-294)", () => { expect(next(human)).toBe(hands_on); }); + // The hard skip above keyed on actor.type, which conflated two different + // questions: "is this a person" and "is a person watching this session". + // An interactive Claude session is an agent by type but is human-attended, + // and it is exactly the caller `interactive` work is meant for -- yet it was + // routed away from it, so the top of the curated queue was invisible to the + // only non-human caller that could do it. + it("hands an `interactive` issue to an ATTENDED agent, not just a human", () => { + const session = createActor(db, { + name: "claude/interactive", + type: "agent", + attended: true, + }).actor; + const hands_on = todo("needs a person", { workerPreference: "interactive" }); + setQueuePosition(db, human, hands_on, { position: 1 }); + expect(next(claude)).toBeNull(); // unattended dispatch worker: still skipped + expect(next(session)).toBe(hands_on); + expect(next(human)).toBe(hands_on); + }); + + it("defaults an agent to unattended — the safe direction, since it only withholds work", () => { + const plain = createActor(db, { name: "claude/other", type: "agent" }).actor; + expect(plain.attended).toBe(false); + const hands_on = todo("needs a person", { workerPreference: "interactive" }); + setQueuePosition(db, human, hands_on, { position: 1 }); + expect(next(plain)).toBeNull(); + }); + + it("treats a human as attended without anyone setting a flag", () => { + expect(human.attended).toBe(true); + }); + it("skips a queued interactive issue rather than stalling on it", () => { const hands_on = todo("needs a person", { workerPreference: "interactive" }); const headless = todo("headless-ok");