agent-relay-server 0.106.0 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +503 -1
- package/package.json +6 -5
- package/public/assets/{MessageBubble-CPYQQLDI.js → MessageBubble-DYJezSjU.js} +2 -2
- package/public/assets/{MessageBubble-CPYQQLDI.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-B7xZt5DI.js → PlanGraphPanel-MWcVw38W.js} +2 -2
- package/public/assets/{PlanGraphPanel-B7xZt5DI.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
- package/public/assets/{activity-UKQrQ8_0.js → activity-Db3X3dg8.js} +2 -2
- package/public/assets/{activity-UKQrQ8_0.js.map → activity-Db3X3dg8.js.map} +1 -1
- package/public/assets/{agent-profiles-Zr_5nNEJ.js → agent-profiles-Di2w5U1o.js} +2 -2
- package/public/assets/{agent-profiles-Zr_5nNEJ.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
- package/public/assets/{agents-FdrfmJsj.js → agents-GSJXvmU1.js} +2 -2
- package/public/assets/{agents-FdrfmJsj.js.map → agents-GSJXvmU1.js.map} +1 -1
- package/public/assets/{analytics-VximqXQ9.js → analytics-B9USQLl5.js} +18 -18
- package/public/assets/{analytics-VximqXQ9.js.map → analytics-B9USQLl5.js.map} +1 -1
- package/public/assets/{automation-DlEcJvgu.js → automation-XRePs1dT.js} +2 -2
- package/public/assets/{automation-DlEcJvgu.js.map → automation-XRePs1dT.js.map} +1 -1
- package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-C_VNyER3.js} +2 -2
- package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-C_VNyER3.js.map} +1 -1
- package/public/assets/{channels-CcSnLzCG.js → channels-DACvNoC2.js} +2 -2
- package/public/assets/{channels-CcSnLzCG.js.map → channels-DACvNoC2.js.map} +1 -1
- package/public/assets/{chat-D1bunuI-.js → chat-D_0uHgB6.js} +2 -2
- package/public/assets/{chat-D1bunuI-.js.map → chat-D_0uHgB6.js.map} +1 -1
- package/public/assets/{connectors-DbPOlQhL.js → connectors-C0B38LUK.js} +2 -2
- package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C0B38LUK.js.map} +1 -1
- package/public/assets/display-1J__Ti1K.js +3 -0
- package/public/assets/display-1J__Ti1K.js.map +1 -0
- package/public/assets/{formatted-body-impl-4v9SkUD1.js → formatted-body-impl-BKWCAjBd.js} +2 -2
- package/public/assets/{formatted-body-impl-4v9SkUD1.js.map → formatted-body-impl-BKWCAjBd.js.map} +1 -1
- package/public/assets/index-DlBSuzd2.js +23 -0
- package/public/assets/index-DlBSuzd2.js.map +1 -0
- package/public/assets/{insights-CntgHcTp.js → insights-BzNoYvDe.js} +2 -2
- package/public/assets/{insights-CntgHcTp.js.map → insights-BzNoYvDe.js.map} +1 -1
- package/public/assets/{integrations-hkSW8CzM.js → integrations-DLzJpt9G.js} +2 -2
- package/public/assets/{integrations-hkSW8CzM.js.map → integrations-DLzJpt9G.js.map} +1 -1
- package/public/assets/{maintenance-Dw7frQtu.js → maintenance-B6_0-ECm.js} +2 -2
- package/public/assets/{maintenance-Dw7frQtu.js.map → maintenance-B6_0-ECm.js.map} +1 -1
- package/public/assets/{managed-agents-Bj--bFmZ.js → managed-agents-CpzKXDtV.js} +2 -2
- package/public/assets/{managed-agents-Bj--bFmZ.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DGLCocU8.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
- package/public/assets/{markdown-preview-impl-DGLCocU8.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
- package/public/assets/{memory-yj2MIxUA.js → memory-Bnm7ILWL.js} +2 -2
- package/public/assets/{memory-yj2MIxUA.js.map → memory-Bnm7ILWL.js.map} +1 -1
- package/public/assets/{messages-BT5mL7iz.js → messages-D9LkocTc.js} +2 -2
- package/public/assets/{messages-BT5mL7iz.js.map → messages-D9LkocTc.js.map} +1 -1
- package/public/assets/{orchestrators-BVDAhrPk.js → orchestrators-C__JfrNr.js} +2 -2
- package/public/assets/{orchestrators-BVDAhrPk.js.map → orchestrators-C__JfrNr.js.map} +1 -1
- package/public/assets/{overview-DlqJIqYK.js → overview-BQ0b0V7Q.js} +2 -2
- package/public/assets/{overview-DlqJIqYK.js.map → overview-BQ0b0V7Q.js.map} +1 -1
- package/public/assets/{pairs-DQ4tNCo3.js → pairs-ChyeBs0Z.js} +2 -2
- package/public/assets/{pairs-DQ4tNCo3.js.map → pairs-ChyeBs0Z.js.map} +1 -1
- package/public/assets/{projects-vt63bwHN.js → projects-v5Kp1Oaq.js} +2 -2
- package/public/assets/{projects-vt63bwHN.js.map → projects-v5Kp1Oaq.js.map} +1 -1
- package/public/assets/{prompt-settings-DlsqOP0C.js → prompt-settings-YhAOlYc6.js} +2 -2
- package/public/assets/{prompt-settings-DlsqOP0C.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
- package/public/assets/{registry-Cw2fdWrn.js → registry-DZoAfrzx.js} +2 -2
- package/public/assets/{registry-Cw2fdWrn.js.map → registry-DZoAfrzx.js.map} +1 -1
- package/public/assets/{security-ucr7IT1V.js → security-DRCj00eA.js} +2 -2
- package/public/assets/{security-ucr7IT1V.js.map → security-DRCj00eA.js.map} +1 -1
- package/public/assets/{select-D8UWCtYZ.js → select-ZOpLw2Fd.js} +2 -2
- package/public/assets/{select-D8UWCtYZ.js.map → select-ZOpLw2Fd.js.map} +1 -1
- package/public/assets/settings-DGgwr9WH.js +6 -0
- package/public/assets/settings-DGgwr9WH.js.map +1 -0
- package/public/assets/{store-DUfrGvOE.js → store-Bnag1kY-.js} +2 -2
- package/public/assets/{store-DUfrGvOE.js.map → store-Bnag1kY-.js.map} +1 -1
- package/public/assets/{tasks-jRUvxUd5.js → tasks-DUTu8ZhA.js} +2 -2
- package/public/assets/{tasks-jRUvxUd5.js.map → tasks-DUTu8ZhA.js.map} +1 -1
- package/public/assets/{teams-Dn0yggD-.js → teams-BJpaoG2m.js} +2 -2
- package/public/assets/{teams-Dn0yggD-.js.map → teams-BJpaoG2m.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-fLly20IP.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
- package/public/assets/{terminal-viewer-impl-fLly20IP.js.map → terminal-viewer-impl-CQhh6oXa.js.map} +1 -1
- package/public/assets/types-BiJFuTiL.js +2 -0
- package/public/assets/types-BiJFuTiL.js.map +1 -0
- package/public/assets/{user-avatar-BWLUHt2e.js → user-avatar-BYGrUx1U.js} +2 -2
- package/public/assets/{user-avatar-BWLUHt2e.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
- package/public/assets/{work-queue-BAnNY0xE.js → work-queue-BH2-mARc.js} +2 -2
- package/public/assets/{work-queue-BAnNY0xE.js.map → work-queue-BH2-mARc.js.map} +1 -1
- package/public/assets/{workspaces-CIsQqQby.js → workspaces-DldYyXZR.js} +2 -2
- package/public/assets/{workspaces-CIsQqQby.js.map → workspaces-DldYyXZR.js.map} +1 -1
- package/public/index.html +4 -4
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
- package/src/branch-landed.ts +93 -4
- package/src/config-store.ts +12 -1
- package/src/db/agents.ts +3 -2
- package/src/db/continuations.ts +13 -3
- package/src/db/index.ts +3 -0
- package/src/db/issue-lifecycle.ts +12 -0
- package/src/db/mappers.ts +17 -0
- package/src/db/messages.ts +9 -4
- package/src/db/migrations.ts +91 -1
- package/src/db/notification-subscriptions.ts +1 -1
- package/src/db/prompt-templates.ts +19 -0
- package/src/db/provider-quotas.ts +6 -0
- package/src/db/schema.ts +67 -1
- package/src/db/stats.ts +3 -2
- package/src/db/task-comment-through.ts +112 -0
- package/src/db/task-core.ts +14 -1
- package/src/db/task-entity.ts +629 -0
- package/src/db/task-maintenance.ts +4 -3
- package/src/db/task-memory.ts +180 -0
- package/src/db/tasks.ts +24 -10
- package/src/issue-tracker/adapter.ts +15 -0
- package/src/issue-tracker/github.ts +40 -2
- package/src/issue-tracker/index.ts +1 -0
- package/src/mcp/index.ts +12 -1
- package/src/mcp/tools-spawn.ts +4 -0
- package/src/mcp-dispatch-tools.ts +164 -0
- package/src/mcp-notification-subscription-tools.ts +62 -4
- package/src/mcp-plan-tools.ts +30 -3
- package/src/mcp-release-tools.ts +33 -0
- package/src/mcp-task-memory-tools.ts +132 -0
- package/src/mcp-task-tools.ts +232 -0
- package/src/mcp-task-weave-tools.ts +66 -0
- package/src/notification-router.ts +24 -2
- package/src/notification-rules.ts +4 -0
- package/src/notification-types.ts +25 -0
- package/src/prompt-resolver.ts +66 -3
- package/src/routes/agents-spawn.ts +10 -0
- package/src/routes/commands.ts +17 -1
- package/src/routes/index.ts +16 -2
- package/src/routes/plans.ts +10 -2
- package/src/routes/provider-quotas.ts +13 -1
- package/src/routes/tasks.ts +276 -2
- package/src/routing-policy-store.ts +147 -2
- package/src/security.ts +2 -0
- package/src/self-resume.ts +13 -2
- package/src/services/issue-lifecycle.ts +9 -1
- package/src/services/plan-graph.ts +107 -9
- package/src/services/release-readiness.ts +142 -0
- package/src/services/send-message.ts +31 -0
- package/src/services/spawn-agent.ts +81 -4
- package/src/services/task-context-hydration.ts +361 -0
- package/src/services/task-entity.ts +149 -0
- package/src/services/task-issue-weave.ts +558 -0
- package/src/services/task-lifecycle.ts +465 -0
- package/src/services/task-plan-view.ts +178 -0
- package/src/services/task-semantic-events.ts +137 -0
- package/src/services/transient-agent-reaper.ts +71 -23
- package/src/spawn-targets.ts +27 -2
- package/src/task-dispatcher.ts +461 -0
- package/src/upgrade-install-verify.ts +20 -1
- package/public/assets/display-BWi8DQCp.js +0 -3
- package/public/assets/display-BWi8DQCp.js.map +0 -1
- package/public/assets/index-Vmhw442d.js +0 -23
- package/public/assets/index-Vmhw442d.js.map +0 -1
- package/public/assets/settings-Dvf_ps1B.js +0 -6
- package/public/assets/settings-Dvf_ps1B.js.map +0 -1
- package/public/assets/types-DorDDvEb.js +0 -2
- package/public/assets/types-DorDDvEb.js.map +0 -1
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// #834 — the durable first-class Task entity (keystone of #830).
|
|
2
|
+
//
|
|
3
|
+
// This module owns the `kind='deliverable'` facet of the `tasks` table: the durable
|
|
4
|
+
// coordination Task that threads issues / branch / commits / dependencies / quality gates.
|
|
5
|
+
// The historical claim-lane lives in tasks.ts and is fenced off by `claimTaskWhere`
|
|
6
|
+
// (see task-core.ts) — the two facets share a table but never each other's machinery.
|
|
7
|
+
//
|
|
8
|
+
// Source-of-truth boundary (#830/#833): GitHub owns intent/spec/acceptance/discussion;
|
|
9
|
+
// the Task owns execution telemetry (status, deliverable stage, gate results, branch,
|
|
10
|
+
// commits, deps, attribution). Issue links are a PROJECTION of issue_associations
|
|
11
|
+
// (entity_type='task') — never a duplicated column — so they cannot drift.
|
|
12
|
+
import {
|
|
13
|
+
TASK_DELIVERABLE_STAGES,
|
|
14
|
+
TASK_BLOCKER_TYPES,
|
|
15
|
+
TASK_GATE_STATUSES,
|
|
16
|
+
type Task,
|
|
17
|
+
type TaskBlocker,
|
|
18
|
+
type TaskCommitRef,
|
|
19
|
+
type TaskDeliverableStage,
|
|
20
|
+
type TaskDependency,
|
|
21
|
+
type TaskDetail,
|
|
22
|
+
type TaskDispatch,
|
|
23
|
+
type TaskGateState,
|
|
24
|
+
type TaskGateStatus,
|
|
25
|
+
type TaskIssueLink,
|
|
26
|
+
type TaskStatus,
|
|
27
|
+
} from "../types";
|
|
28
|
+
import { parseJson } from "../utils";
|
|
29
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
30
|
+
import { rowToTask } from "./mappers.ts";
|
|
31
|
+
import { TASK_SELECT, insertTaskEvent, DELIVERABLE_TASK_KIND } from "./task-core.ts";
|
|
32
|
+
import { associateIssue, listIssuesForEntity, removeAssociation } from "./issue-associations.ts";
|
|
33
|
+
import { resolveIssueRefFromString } from "./issue-refs.ts";
|
|
34
|
+
|
|
35
|
+
// Execution-status axis (#834): the existing claim FSM vocabulary, reused so there is no
|
|
36
|
+
// enum break. not-started=open, working=in_progress, plus claimed/blocked/done/failed/canceled.
|
|
37
|
+
const DELIVERABLE_STATUSES: readonly TaskStatus[] = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"];
|
|
38
|
+
const GATE_EVENT_PREFIX = "gate:";
|
|
39
|
+
|
|
40
|
+
function taskEntityId(id: string): string {
|
|
41
|
+
return String(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireText(value: unknown, field: string, max: number): string {
|
|
45
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${field} required`);
|
|
46
|
+
const cleaned = value.trim();
|
|
47
|
+
if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
|
|
48
|
+
return cleaned;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function optionalText(value: unknown, field: string, max: number): string | undefined {
|
|
52
|
+
if (value === undefined || value === null) return undefined;
|
|
53
|
+
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
54
|
+
const cleaned = value.trim();
|
|
55
|
+
if (!cleaned) return undefined;
|
|
56
|
+
if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
|
|
57
|
+
return cleaned;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cleanBlockedBy(value: unknown): TaskBlocker[] | undefined {
|
|
61
|
+
if (value === undefined || value === null) return undefined;
|
|
62
|
+
if (!Array.isArray(value)) throw new ValidationError("blockedBy must be an array");
|
|
63
|
+
return value.map((entry, i) => {
|
|
64
|
+
if (typeof entry !== "object" || entry === null) throw new ValidationError(`blockedBy[${i}] must be an object`);
|
|
65
|
+
const type = (entry as any).type;
|
|
66
|
+
if (!TASK_BLOCKER_TYPES.includes(type)) throw new ValidationError(`blockedBy[${i}].type must be one of: ${TASK_BLOCKER_TYPES.join(", ")}`);
|
|
67
|
+
return {
|
|
68
|
+
type,
|
|
69
|
+
...(optionalText((entry as any).ref, "blockedBy.ref", 240) ? { ref: optionalText((entry as any).ref, "blockedBy.ref", 240)! } : {}),
|
|
70
|
+
...(optionalText((entry as any).note, "blockedBy.note", 2000) ? { note: optionalText((entry as any).note, "blockedBy.note", 2000)! } : {}),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cleanDispatch(value: unknown): TaskDispatch | undefined {
|
|
76
|
+
if (value === undefined || value === null) return undefined;
|
|
77
|
+
if (typeof value !== "object") throw new ValidationError("dispatch must be an object");
|
|
78
|
+
const d = value as Record<string, unknown>;
|
|
79
|
+
const out: TaskDispatch = {};
|
|
80
|
+
if (d.complexity !== undefined) out.complexity = d.complexity as TaskDispatch["complexity"];
|
|
81
|
+
if (d.type !== undefined) out.type = d.type as TaskDispatch["type"];
|
|
82
|
+
if (d.contextFootprint !== undefined) out.contextFootprint = d.contextFootprint as TaskDispatch["contextFootprint"];
|
|
83
|
+
if (d.criticality !== undefined) out.criticality = d.criticality as TaskDispatch["criticality"];
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getRawTask(id: number): Task | null {
|
|
88
|
+
const row = getDb().query(`${TASK_SELECT} WHERE id = ?`).get(id) as any;
|
|
89
|
+
return row ? rowToTask(row) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requireDeliverable(id: number): Task {
|
|
93
|
+
const task = getRawTask(id);
|
|
94
|
+
if (!task) throw new ValidationError(`task ${id} not found`);
|
|
95
|
+
if (task.kind !== DELIVERABLE_TASK_KIND) throw new ValidationError(`task ${id} is not a deliverable task`);
|
|
96
|
+
return task;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CreateDeliverableTaskInput {
|
|
100
|
+
title: string;
|
|
101
|
+
intent?: string;
|
|
102
|
+
doneWhen?: string;
|
|
103
|
+
teamId?: string;
|
|
104
|
+
createdBy: string;
|
|
105
|
+
source?: string;
|
|
106
|
+
target?: string;
|
|
107
|
+
latestSteer?: string;
|
|
108
|
+
dispatch?: TaskDispatch;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createDeliverableTask(input: CreateDeliverableTaskInput): TaskDetail {
|
|
112
|
+
const title = requireText(input.title, "title", 500);
|
|
113
|
+
const createdBy = requireText(input.createdBy, "createdBy", 200);
|
|
114
|
+
const intent = optionalText(input.intent, "intent", 1_000_000);
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const dispatch = cleanDispatch(input.dispatch);
|
|
117
|
+
// Legacy NOT-NULL columns have no DB defaults (#834 finding 6) — set them explicitly.
|
|
118
|
+
const result = getDb().query(`
|
|
119
|
+
INSERT INTO tasks (
|
|
120
|
+
source, title, body, severity, status, target, occurrence_count, metadata,
|
|
121
|
+
created_at, updated_at, last_seen_at,
|
|
122
|
+
kind, intent, latest_steer, deliverable_stage, done_when, team_id, created_by, dispatch
|
|
123
|
+
) VALUES (?, ?, ?, 'info', 'open', ?, 1, '{}', ?, ?, ?, '${DELIVERABLE_TASK_KIND}', ?, ?, 'none', ?, ?, ?, ?)
|
|
124
|
+
`).run(
|
|
125
|
+
optionalText(input.source, "source", 200) ?? "coordinator",
|
|
126
|
+
title,
|
|
127
|
+
intent ?? title,
|
|
128
|
+
optionalText(input.target, "target", 200) ?? createdBy,
|
|
129
|
+
now, now, now,
|
|
130
|
+
intent ?? null,
|
|
131
|
+
optionalText(input.latestSteer, "latestSteer", 1_000_000) ?? null,
|
|
132
|
+
optionalText(input.doneWhen, "doneWhen", 1_000_000) ?? null,
|
|
133
|
+
optionalText(input.teamId, "teamId", 200) ?? null,
|
|
134
|
+
createdBy,
|
|
135
|
+
dispatch ? JSON.stringify(dispatch) : null,
|
|
136
|
+
);
|
|
137
|
+
const id = Number(result.lastInsertRowid);
|
|
138
|
+
insertTaskEvent(id, {
|
|
139
|
+
source: createdBy,
|
|
140
|
+
type: "task.created",
|
|
141
|
+
severity: "info",
|
|
142
|
+
title: `Task created: ${title}`,
|
|
143
|
+
body: intent ?? "",
|
|
144
|
+
metadata: { createdBy, ...(dispatch ? { dispatch } : {}) },
|
|
145
|
+
}, now);
|
|
146
|
+
return getTaskDetail(id)!;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface UpdateDeliverableTaskInput {
|
|
150
|
+
title?: string;
|
|
151
|
+
intent?: string;
|
|
152
|
+
latestSteer?: string;
|
|
153
|
+
doneWhen?: string;
|
|
154
|
+
dispatch?: TaskDispatch;
|
|
155
|
+
teamId?: string;
|
|
156
|
+
actor?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function updateDeliverableTask(id: number, input: UpdateDeliverableTaskInput): TaskDetail {
|
|
160
|
+
requireDeliverable(id);
|
|
161
|
+
const sets: string[] = [];
|
|
162
|
+
const params: unknown[] = [];
|
|
163
|
+
const push = (col: string, val: unknown) => { sets.push(`${col} = ?`); params.push(val); };
|
|
164
|
+
if (input.title !== undefined) push("title", requireText(input.title, "title", 500));
|
|
165
|
+
if (input.intent !== undefined) push("intent", optionalText(input.intent, "intent", 1_000_000) ?? null);
|
|
166
|
+
if (input.latestSteer !== undefined) push("latest_steer", optionalText(input.latestSteer, "latestSteer", 1_000_000) ?? null);
|
|
167
|
+
if (input.doneWhen !== undefined) push("done_when", optionalText(input.doneWhen, "doneWhen", 1_000_000) ?? null);
|
|
168
|
+
if (input.teamId !== undefined) push("team_id", optionalText(input.teamId, "teamId", 200) ?? null);
|
|
169
|
+
if (input.dispatch !== undefined) { const d = cleanDispatch(input.dispatch); push("dispatch", d ? JSON.stringify(d) : null); }
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
if (sets.length > 0) {
|
|
172
|
+
params.push(now, id);
|
|
173
|
+
getDb().query(`UPDATE tasks SET ${sets.join(", ")}, updated_at = ? WHERE id = ?`).run(...params as any[]);
|
|
174
|
+
}
|
|
175
|
+
insertTaskEvent(id, {
|
|
176
|
+
source: input.actor ?? "agent-relay",
|
|
177
|
+
type: "task.updated",
|
|
178
|
+
severity: "info",
|
|
179
|
+
title: "Task updated",
|
|
180
|
+
body: input.latestSteer ? `steer: ${input.latestSteer}` : "",
|
|
181
|
+
metadata: { fields: sets.map((s) => s.split(" = ")[0]) },
|
|
182
|
+
}, now);
|
|
183
|
+
return getTaskDetail(id)!;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface SetTaskStatusInput {
|
|
187
|
+
status: TaskStatus;
|
|
188
|
+
blockedBy?: TaskBlocker[];
|
|
189
|
+
note?: string;
|
|
190
|
+
actor?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Execution-status transition (#834). NEVER touches claimed_by — that column is exclusive to
|
|
194
|
+
// the claim lane, so the claim-lease maintenance machinery can never act on a deliverable Task.
|
|
195
|
+
export function setTaskStatus(id: number, input: SetTaskStatusInput): TaskDetail {
|
|
196
|
+
requireDeliverable(id);
|
|
197
|
+
if (!DELIVERABLE_STATUSES.includes(input.status)) {
|
|
198
|
+
throw new ValidationError(`status must be one of: ${DELIVERABLE_STATUSES.join(", ")}`);
|
|
199
|
+
}
|
|
200
|
+
const blockedBy = cleanBlockedBy(input.blockedBy);
|
|
201
|
+
if (input.status === "blocked" && (!blockedBy || blockedBy.length === 0)) {
|
|
202
|
+
throw new ValidationError("blockedBy is required (non-empty) when status is 'blocked'");
|
|
203
|
+
}
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
// Clearing the blocker set when leaving 'blocked' keeps the row self-consistent.
|
|
206
|
+
const blockedJson = input.status === "blocked" ? JSON.stringify(blockedBy) : null;
|
|
207
|
+
getDb().query("UPDATE tasks SET status = ?, blocked_by = ?, updated_at = ?, last_seen_at = ? WHERE id = ?")
|
|
208
|
+
.run(input.status, blockedJson, now, now, id);
|
|
209
|
+
insertTaskEvent(id, {
|
|
210
|
+
source: input.actor ?? "agent-relay",
|
|
211
|
+
type: `status:${input.status}`,
|
|
212
|
+
severity: "info",
|
|
213
|
+
title: `Task ${input.status}`,
|
|
214
|
+
body: input.note ?? "",
|
|
215
|
+
metadata: { status: input.status, ...(blockedBy ? { blockedBy } : {}) },
|
|
216
|
+
}, now);
|
|
217
|
+
return getTaskDetail(id)!;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface SetTaskStageInput {
|
|
221
|
+
deliverableStage: TaskDeliverableStage;
|
|
222
|
+
branch?: string;
|
|
223
|
+
commit?: TaskCommitRef;
|
|
224
|
+
actor?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Deliverable-position transition (#834). Designed to be driven OBSERVABLY by the land/deploy
|
|
228
|
+
// pipeline (#831 wires it); exposed here for manual/test use. Composes with status, never claims.
|
|
229
|
+
export function setTaskStage(id: number, input: SetTaskStageInput): TaskDetail {
|
|
230
|
+
requireDeliverable(id);
|
|
231
|
+
if (!TASK_DELIVERABLE_STAGES.includes(input.deliverableStage)) {
|
|
232
|
+
throw new ValidationError(`deliverableStage must be one of: ${TASK_DELIVERABLE_STAGES.join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const sets = ["deliverable_stage = ?"];
|
|
236
|
+
const params: unknown[] = [input.deliverableStage];
|
|
237
|
+
const branch = optionalText(input.branch, "branch", 500);
|
|
238
|
+
if (branch) { sets.push("branch = ?"); params.push(branch); }
|
|
239
|
+
if (input.commit) appendCommitRow(id, input.commit);
|
|
240
|
+
params.push(now, now, id);
|
|
241
|
+
getDb().query(`UPDATE tasks SET ${sets.join(", ")}, updated_at = ?, last_seen_at = ? WHERE id = ?`).run(...params as any[]);
|
|
242
|
+
insertTaskEvent(id, {
|
|
243
|
+
source: input.actor ?? "agent-relay",
|
|
244
|
+
type: `stage:${input.deliverableStage}`,
|
|
245
|
+
severity: "info",
|
|
246
|
+
title: `Deliverable stage: ${input.deliverableStage}`,
|
|
247
|
+
body: branch ? `branch ${branch}` : "",
|
|
248
|
+
metadata: { deliverableStage: input.deliverableStage, ...(branch ? { branch } : {}), ...(input.commit ? { commit: input.commit } : {}) },
|
|
249
|
+
}, now);
|
|
250
|
+
return getTaskDetail(id)!;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface RecordGateInput {
|
|
254
|
+
gate: string;
|
|
255
|
+
status: TaskGateStatus;
|
|
256
|
+
detail?: string;
|
|
257
|
+
commitSha?: string;
|
|
258
|
+
runId?: string;
|
|
259
|
+
actor?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Quality-gate ledger write (#834). The append-only `task_events` IS the ledger (no second
|
|
263
|
+
// system of record). Idempotent by runId: the SAME run reported twice does not double-append,
|
|
264
|
+
// so a retried report cannot read as a duplicate pass. A genuine re-run (new runId) appends a
|
|
265
|
+
// higher-id event, which `latestGates` deterministically surfaces as the current state.
|
|
266
|
+
export function recordGate(id: number, input: RecordGateInput): TaskGateState {
|
|
267
|
+
requireDeliverable(id);
|
|
268
|
+
const gate = requireText(input.gate, "gate", 120);
|
|
269
|
+
if (!TASK_GATE_STATUSES.includes(input.status)) {
|
|
270
|
+
throw new ValidationError(`gate status must be one of: ${TASK_GATE_STATUSES.join(", ")}`);
|
|
271
|
+
}
|
|
272
|
+
const type = `${GATE_EVENT_PREFIX}${gate}`;
|
|
273
|
+
const runId = optionalText(input.runId, "runId", 240);
|
|
274
|
+
if (runId) {
|
|
275
|
+
const existing = getDb()
|
|
276
|
+
.query(`SELECT * FROM task_events WHERE task_id = ? AND type = ? AND json_extract(metadata, '$.runId') = ? LIMIT 1`)
|
|
277
|
+
.get(id, type, runId) as any;
|
|
278
|
+
if (existing) return gateStateFromEvent(existing);
|
|
279
|
+
}
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
const event = insertTaskEvent(id, {
|
|
282
|
+
source: input.actor ?? "agent-relay",
|
|
283
|
+
type,
|
|
284
|
+
severity: input.status === "fail" ? "warning" : "info",
|
|
285
|
+
title: `Gate ${gate}: ${input.status}`,
|
|
286
|
+
body: input.detail ?? "",
|
|
287
|
+
metadata: {
|
|
288
|
+
gate,
|
|
289
|
+
status: input.status,
|
|
290
|
+
...(input.detail ? { detail: input.detail } : {}),
|
|
291
|
+
...(input.commitSha ? { commitSha: input.commitSha } : {}),
|
|
292
|
+
...(runId ? { runId } : {}),
|
|
293
|
+
},
|
|
294
|
+
}, now);
|
|
295
|
+
return gateStateFromEvent({ type, created_at: event.createdAt, metadata: JSON.stringify(event.metadata) });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function gateStateFromEvent(row: { type: string; created_at: number; metadata: string }): TaskGateState {
|
|
299
|
+
const meta = parseJson<Record<string, unknown>>(row.metadata, {});
|
|
300
|
+
return {
|
|
301
|
+
gate: typeof meta.gate === "string" ? meta.gate : row.type.slice(GATE_EVENT_PREFIX.length),
|
|
302
|
+
status: (meta.status as TaskGateStatus) ?? "fail",
|
|
303
|
+
...(typeof meta.detail === "string" ? { detail: meta.detail } : {}),
|
|
304
|
+
...(typeof meta.commitSha === "string" ? { commitSha: meta.commitSha } : {}),
|
|
305
|
+
...(typeof meta.runId === "string" ? { runId: meta.runId } : {}),
|
|
306
|
+
ranAt: row.created_at,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Deterministic current-state projection over the append-only ledger: the highest-id event
|
|
311
|
+
// per gate type. Re-runs always win; ties are impossible (autoincrement id).
|
|
312
|
+
export function latestGates(id: number): TaskGateState[] {
|
|
313
|
+
const rows = getDb().query(`
|
|
314
|
+
SELECT te.type AS type, te.created_at AS created_at, te.metadata AS metadata
|
|
315
|
+
FROM task_events te
|
|
316
|
+
JOIN (
|
|
317
|
+
SELECT type, MAX(id) AS mid FROM task_events
|
|
318
|
+
WHERE task_id = ? AND type LIKE '${GATE_EVENT_PREFIX}%'
|
|
319
|
+
GROUP BY type
|
|
320
|
+
) m ON te.id = m.mid
|
|
321
|
+
ORDER BY te.type ASC
|
|
322
|
+
`).all(id) as Array<{ type: string; created_at: number; metadata: string }>;
|
|
323
|
+
return rows.map(gateStateFromEvent);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- Dependency DAG (#834): task→task edges, cycle-rejected transactionally (#834 finding 7).
|
|
327
|
+
// Edge semantics: `taskId` depends on `dependsOnTaskId` (the latter must complete first).
|
|
328
|
+
export function addTaskDependency(taskId: number, dependsOnTaskId: number, actor?: string): TaskDependency {
|
|
329
|
+
if (taskId === dependsOnTaskId) throw new ValidationError("a task cannot depend on itself");
|
|
330
|
+
return getDb().transaction(() => {
|
|
331
|
+
if (!getRawTask(taskId)) throw new ValidationError(`task ${taskId} not found`);
|
|
332
|
+
if (!getRawTask(dependsOnTaskId)) throw new ValidationError(`task ${dependsOnTaskId} not found`);
|
|
333
|
+
// Cycle check inside the write transaction: if `dependsOnTaskId` can already reach
|
|
334
|
+
// `taskId` along dependency edges, adding this edge would close a cycle. Loading the
|
|
335
|
+
// reachable set here (not from a stale in-memory snapshot) makes concurrent inserts safe.
|
|
336
|
+
const visited = new Set<number>();
|
|
337
|
+
const stack = [dependsOnTaskId];
|
|
338
|
+
while (stack.length > 0) {
|
|
339
|
+
const cur = stack.pop()!;
|
|
340
|
+
if (cur === taskId) throw new ValidationError("dependency would create a cycle");
|
|
341
|
+
if (visited.has(cur)) continue;
|
|
342
|
+
visited.add(cur);
|
|
343
|
+
const next = getDb().query("SELECT depends_on_task_id FROM task_dependencies WHERE task_id = ?").all(cur) as Array<{ depends_on_task_id: number }>;
|
|
344
|
+
for (const n of next) stack.push(n.depends_on_task_id);
|
|
345
|
+
}
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
getDb().query("INSERT INTO task_dependencies (task_id, depends_on_task_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING")
|
|
348
|
+
.run(taskId, dependsOnTaskId, now);
|
|
349
|
+
insertTaskEvent(taskId, {
|
|
350
|
+
source: actor ?? "agent-relay",
|
|
351
|
+
type: "dependency.added",
|
|
352
|
+
severity: "info",
|
|
353
|
+
title: `Depends on task ${dependsOnTaskId}`,
|
|
354
|
+
body: "",
|
|
355
|
+
metadata: { dependsOnTaskId },
|
|
356
|
+
}, now);
|
|
357
|
+
return { taskId, dependsOnTaskId, createdAt: now };
|
|
358
|
+
})();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function removeTaskDependency(taskId: number, dependsOnTaskId: number, actor?: string): boolean {
|
|
362
|
+
const result = getDb().query("DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?").run(taskId, dependsOnTaskId);
|
|
363
|
+
if (result.changes > 0) {
|
|
364
|
+
insertTaskEvent(taskId, {
|
|
365
|
+
source: actor ?? "agent-relay",
|
|
366
|
+
type: "dependency.removed",
|
|
367
|
+
severity: "info",
|
|
368
|
+
title: `Removed dependency on task ${dependsOnTaskId}`,
|
|
369
|
+
body: "",
|
|
370
|
+
metadata: { dependsOnTaskId },
|
|
371
|
+
}, Date.now());
|
|
372
|
+
}
|
|
373
|
+
return result.changes > 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function listTaskDependencies(taskId: number): number[] {
|
|
377
|
+
return (getDb().query("SELECT depends_on_task_id FROM task_dependencies WHERE task_id = ? ORDER BY depends_on_task_id ASC").all(taskId) as Array<{ depends_on_task_id: number }>)
|
|
378
|
+
.map((r) => r.depends_on_task_id);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function listTaskDependents(taskId: number): number[] {
|
|
382
|
+
return (getDb().query("SELECT task_id FROM task_dependencies WHERE depends_on_task_id = ? ORDER BY task_id ASC").all(taskId) as Array<{ task_id: number }>)
|
|
383
|
+
.map((r) => r.task_id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- Refs: issue links (via issue_associations, the source of truth) + branch + commits.
|
|
387
|
+
export interface AddTaskRefInput {
|
|
388
|
+
issue?: string;
|
|
389
|
+
defaultRepo?: string;
|
|
390
|
+
branch?: string;
|
|
391
|
+
commit?: TaskCommitRef;
|
|
392
|
+
actor?: string;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function addTaskRef(id: number, input: AddTaskRefInput): TaskDetail {
|
|
396
|
+
requireDeliverable(id);
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
if (input.issue) {
|
|
399
|
+
const ref = resolveIssueRefFromString({ ref: input.issue, defaultRepo: optionalText(input.defaultRepo, "defaultRepo", 500) });
|
|
400
|
+
associateIssue(ref.id, "task", taskEntityId(String(id)));
|
|
401
|
+
insertTaskEvent(id, {
|
|
402
|
+
source: input.actor ?? "agent-relay",
|
|
403
|
+
type: "ref.issue.added",
|
|
404
|
+
severity: "info",
|
|
405
|
+
title: `Linked issue ${ref.repo}#${ref.externalKey}`,
|
|
406
|
+
body: ref.url,
|
|
407
|
+
metadata: { issueRefId: ref.id, provider: ref.provider, repo: ref.repo, externalKey: ref.externalKey },
|
|
408
|
+
}, now);
|
|
409
|
+
}
|
|
410
|
+
const branch = optionalText(input.branch, "branch", 500);
|
|
411
|
+
if (branch) {
|
|
412
|
+
getDb().query("UPDATE tasks SET branch = ?, updated_at = ? WHERE id = ?").run(branch, now, id);
|
|
413
|
+
insertTaskEvent(id, { source: input.actor ?? "agent-relay", type: "ref.branch.set", severity: "info", title: `Branch ${branch}`, body: "", metadata: { branch } }, now);
|
|
414
|
+
}
|
|
415
|
+
if (input.commit) {
|
|
416
|
+
appendCommitRow(id, input.commit);
|
|
417
|
+
insertTaskEvent(id, { source: input.actor ?? "agent-relay", type: "ref.commit.added", severity: "info", title: `Commit ${input.commit.sha}`, body: input.commit.subject ?? "", metadata: { commit: input.commit } }, now);
|
|
418
|
+
}
|
|
419
|
+
return getTaskDetail(id)!;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface RemoveTaskRefInput {
|
|
423
|
+
issueRefId?: string;
|
|
424
|
+
branch?: boolean;
|
|
425
|
+
commitSha?: string;
|
|
426
|
+
actor?: string;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function removeTaskRef(id: number, input: RemoveTaskRefInput): TaskDetail {
|
|
430
|
+
requireDeliverable(id);
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
if (input.issueRefId) {
|
|
433
|
+
removeAssociation(requireText(input.issueRefId, "issueRefId", 120), "task", taskEntityId(String(id)));
|
|
434
|
+
}
|
|
435
|
+
if (input.branch) {
|
|
436
|
+
getDb().query("UPDATE tasks SET branch = NULL, updated_at = ? WHERE id = ?").run(now, id);
|
|
437
|
+
}
|
|
438
|
+
if (input.commitSha) {
|
|
439
|
+
const task = getRawTask(id)!;
|
|
440
|
+
const commits = (task.commits ?? []).filter((c) => c.sha !== input.commitSha);
|
|
441
|
+
getDb().query("UPDATE tasks SET commits = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(commits), now, id);
|
|
442
|
+
}
|
|
443
|
+
return getTaskDetail(id)!;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function appendCommitRow(id: number, commit: TaskCommitRef): void {
|
|
447
|
+
const sha = requireText(commit.sha, "commit.sha", 80);
|
|
448
|
+
const subject = optionalText(commit.subject, "commit.subject", 1000);
|
|
449
|
+
const task = getRawTask(id)!;
|
|
450
|
+
const commits = task.commits ?? [];
|
|
451
|
+
if (commits.some((c) => c.sha === sha)) return;
|
|
452
|
+
commits.push({ sha, ...(subject ? { subject } : {}) });
|
|
453
|
+
getDb().query("UPDATE tasks SET commits = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(commits), Date.now(), id);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// --- Attribution: who did what. Writes owner_agent_id (NOT claimed_by) + an attribution event.
|
|
457
|
+
export function assignTaskAgent(id: number, agentId: string, role?: string, actor?: string): TaskDetail {
|
|
458
|
+
requireDeliverable(id);
|
|
459
|
+
const cleanAgent = requireText(agentId, "agentId", 200);
|
|
460
|
+
const cleanRole = optionalText(role, "role", 120);
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
getDb().query("UPDATE tasks SET owner_agent_id = ?, updated_at = ? WHERE id = ?").run(cleanAgent, now, id);
|
|
463
|
+
insertTaskEvent(id, {
|
|
464
|
+
source: actor ?? cleanAgent,
|
|
465
|
+
type: "agent.assigned",
|
|
466
|
+
severity: "info",
|
|
467
|
+
title: `Assigned ${cleanAgent}${cleanRole ? ` (${cleanRole})` : ""}`,
|
|
468
|
+
body: "",
|
|
469
|
+
metadata: { agentId: cleanAgent, ...(cleanRole ? { role: cleanRole } : {}) },
|
|
470
|
+
}, now);
|
|
471
|
+
return getTaskDetail(id)!;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Attribution: spawn-with-task (#838). Records that a worker was spawned FOR this Task,
|
|
475
|
+
// keyed on its spawnRequestId, BEFORE the child registers (fire-and-forget spawns resolve the
|
|
476
|
+
// agent id later). The append-only event is the durable hook #831 reads to drive one-shot
|
|
477
|
+
// reap (find the spawn by spawnRequestId → on land update status → reap). `agent.`-prefixed so
|
|
478
|
+
// it stays Task-only telemetry and never write-throughs to the issue tracker (#833 boundary).
|
|
479
|
+
export interface RecordTaskSpawnInput {
|
|
480
|
+
spawnRequestId: string;
|
|
481
|
+
provider: string;
|
|
482
|
+
requestedBy: string;
|
|
483
|
+
steer?: string;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Idempotent by spawnRequestId: a retried spawn (orchestrator re-dispatch, client retry) reusing
|
|
487
|
+
// the SAME spawnRequestId must NOT append a second attribution event. The append-only `task_events`
|
|
488
|
+
// is the ledger, so we check-before-insert on the spawnRequestId stamped in metadata (mirrors the
|
|
489
|
+
// runId idempotency in recordGate). NOTE (#838): this dedups the Task ATTRIBUTION only. The
|
|
490
|
+
// command-bus layer is NOT idempotent — `createCommand` always inserts a fresh random-id row and
|
|
491
|
+
// `commands.correlation_id` is indexed but not UNIQUE — so a retried spawnRequestId can still
|
|
492
|
+
// dispatch a duplicate agent.spawn command. Making correlation_id unique ramifies into the broader
|
|
493
|
+
// command/orchestrator-retry path beyond #838's lane; tracked as a separate follow-up.
|
|
494
|
+
export function recordTaskSpawnRequested(id: number, input: RecordTaskSpawnInput): TaskDetail {
|
|
495
|
+
requireDeliverable(id);
|
|
496
|
+
const spawnRequestId = requireText(input.spawnRequestId, "spawnRequestId", 200);
|
|
497
|
+
const provider = requireText(input.provider, "provider", 120);
|
|
498
|
+
const requestedBy = requireText(input.requestedBy, "requestedBy", 200);
|
|
499
|
+
const steer = optionalText(input.steer, "steer", 1_000_000);
|
|
500
|
+
const existing = getDb()
|
|
501
|
+
.query(`SELECT id FROM task_events WHERE task_id = ? AND type = 'agent.spawn_requested' AND json_extract(metadata, '$.spawnRequestId') = ? LIMIT 1`)
|
|
502
|
+
.get(id, spawnRequestId);
|
|
503
|
+
if (existing) return getTaskDetail(id)!;
|
|
504
|
+
insertTaskEvent(id, {
|
|
505
|
+
source: requestedBy,
|
|
506
|
+
type: "agent.spawn_requested",
|
|
507
|
+
severity: "info",
|
|
508
|
+
title: `Worker spawn requested (${provider})`,
|
|
509
|
+
body: steer ?? "",
|
|
510
|
+
metadata: { spawnRequestId, provider, requestedBy, ...(steer ? { steer } : {}) },
|
|
511
|
+
}, Date.now());
|
|
512
|
+
return getTaskDetail(id)!;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- Reads / projections.
|
|
516
|
+
function issueLinks(id: number): TaskIssueLink[] {
|
|
517
|
+
return listIssuesForEntity("task", taskEntityId(String(id))).map((item) => ({
|
|
518
|
+
issueRefId: item.issueRef.id,
|
|
519
|
+
provider: item.issueRef.provider,
|
|
520
|
+
repo: item.issueRef.repo,
|
|
521
|
+
externalKey: item.issueRef.externalKey,
|
|
522
|
+
url: item.issueRef.url,
|
|
523
|
+
...(item.issueRef.title ? { title: item.issueRef.title } : {}),
|
|
524
|
+
status: item.issueRef.status,
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function getTaskDetail(id: number): TaskDetail | null {
|
|
529
|
+
const task = getRawTask(id);
|
|
530
|
+
if (!task || task.kind !== DELIVERABLE_TASK_KIND) return null;
|
|
531
|
+
return {
|
|
532
|
+
...task,
|
|
533
|
+
issues: issueLinks(id),
|
|
534
|
+
dependsOn: listTaskDependencies(id),
|
|
535
|
+
dependents: listTaskDependents(id),
|
|
536
|
+
gates: latestGates(id),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export interface ListDeliverableTasksFilter {
|
|
541
|
+
status?: string;
|
|
542
|
+
deliverableStage?: string;
|
|
543
|
+
teamId?: string;
|
|
544
|
+
limit?: number;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function listDeliverableTasks(filter: ListDeliverableTasksFilter = {}): Task[] {
|
|
548
|
+
const conditions = [`kind = '${DELIVERABLE_TASK_KIND}'`];
|
|
549
|
+
const params: unknown[] = [];
|
|
550
|
+
if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
|
|
551
|
+
if (filter.deliverableStage) { conditions.push("deliverable_stage = ?"); params.push(filter.deliverableStage); }
|
|
552
|
+
if (filter.teamId) { conditions.push("team_id = ?"); params.push(filter.teamId); }
|
|
553
|
+
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
|
|
554
|
+
params.push(limit);
|
|
555
|
+
return (getDb().query(`${TASK_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`).all(...params as any[]) as any[]).map(rowToTask);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Task-aware issue resolution for the land→close bridge (#833/#834 finding 4). Deliverable
|
|
559
|
+
// Tasks own `branch`, so a landed branch resolves to its Task(s). The land WIRING is #831;
|
|
560
|
+
// this is the resolution path + entity-type the bridge needs to be ABLE to fire. The issue
|
|
561
|
+
// lifecycle service consumes these ids via listIssuesForEntity('task', id) — same shape as
|
|
562
|
+
// the existing team/plan-node resolution.
|
|
563
|
+
export function deliverableTaskIdsByBranch(branch: string): number[] {
|
|
564
|
+
const cleaned = branch?.trim();
|
|
565
|
+
if (!cleaned) return [];
|
|
566
|
+
return (getDb().query(`SELECT id FROM tasks WHERE kind = '${DELIVERABLE_TASK_KIND}' AND branch = ? ORDER BY id ASC`).all(cleaned) as Array<{ id: number }>)
|
|
567
|
+
.map((r) => r.id);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Land→Task linkage by attribution (#831/#838). The robust key is the spawned WORKER, not the
|
|
571
|
+
// branch column (a worker cuts its own branch; the Task's `branch` may never have been recorded).
|
|
572
|
+
// `assignTaskAgent` (#838) stamps `owner_agent_id` once the child's id resolves, so the landing
|
|
573
|
+
// workspace's ownerAgentId resolves straight to its bound Task(s).
|
|
574
|
+
export function deliverableTaskIdsByOwnerAgent(agentId: string): number[] {
|
|
575
|
+
const cleaned = agentId?.trim();
|
|
576
|
+
if (!cleaned) return [];
|
|
577
|
+
return (getDb().query(`SELECT id FROM tasks WHERE kind = '${DELIVERABLE_TASK_KIND}' AND owner_agent_id = ? ORDER BY id ASC`).all(cleaned) as Array<{ id: number }>)
|
|
578
|
+
.map((r) => r.id);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Fallback linkage for a fire-and-forget spawn whose `owner_agent_id` was never backfilled (the
|
|
582
|
+
// child registered after the bounded poll window, #838): the `agent.spawn_requested` attribution
|
|
583
|
+
// event (recorded BEFORE the child registers) carries the spawnRequestId. The landing worker's
|
|
584
|
+
// `meta.spawnRequestId` resolves back through it to the Task. Returns every matching task id so a
|
|
585
|
+
// reused spawnRequestId can't silently drop a link.
|
|
586
|
+
export function deliverableTaskIdsBySpawnRequestId(spawnRequestId: string): number[] {
|
|
587
|
+
const cleaned = spawnRequestId?.trim();
|
|
588
|
+
if (!cleaned) return [];
|
|
589
|
+
return (getDb()
|
|
590
|
+
.query(`SELECT DISTINCT te.task_id AS id FROM task_events te
|
|
591
|
+
JOIN tasks t ON t.id = te.task_id AND t.kind = '${DELIVERABLE_TASK_KIND}'
|
|
592
|
+
WHERE te.type = 'agent.spawn_requested' AND json_extract(te.metadata, '$.spawnRequestId') = ?
|
|
593
|
+
ORDER BY te.task_id ASC`)
|
|
594
|
+
.all(cleaned) as Array<{ id: number }>)
|
|
595
|
+
.map((r) => r.id);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Mark that the Task's one-shot worker was reaped on land (#831 reap-signal axis). Records the
|
|
599
|
+
// timestamp on the durable row and appends an `agent.`-prefixed telemetry event (Task-only; never
|
|
600
|
+
// writes through to the issue tracker — see the weave boundary). Idempotent: a second reap on the
|
|
601
|
+
// same land is a no-op marker refresh, so a re-fired land can't double-stamp a different agent.
|
|
602
|
+
export function markTaskAgentReaped(id: number, input: { agentId: string; reason?: string; at?: number }): TaskDetail {
|
|
603
|
+
requireDeliverable(id);
|
|
604
|
+
const agentId = requireText(input.agentId, "agentId", 200);
|
|
605
|
+
const now = input.at ?? Date.now();
|
|
606
|
+
getDb().query("UPDATE tasks SET reaped_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
|
|
607
|
+
insertTaskEvent(id, {
|
|
608
|
+
source: "agent-relay",
|
|
609
|
+
type: "agent.reaped",
|
|
610
|
+
severity: "info",
|
|
611
|
+
title: `Worker ${agentId} reaped`,
|
|
612
|
+
body: input.reason ?? "deliverable landed + gates passed",
|
|
613
|
+
metadata: { agentId, ...(input.reason ? { reason: input.reason } : {}) },
|
|
614
|
+
}, now);
|
|
615
|
+
return getTaskDetail(id)!;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function listIssueRefsForLandedBranch(branch: string): TaskIssueLink[] {
|
|
619
|
+
const out: TaskIssueLink[] = [];
|
|
620
|
+
const seen = new Set<string>();
|
|
621
|
+
for (const id of deliverableTaskIdsByBranch(branch)) {
|
|
622
|
+
for (const link of issueLinks(id)) {
|
|
623
|
+
if (seen.has(link.issueRefId)) continue;
|
|
624
|
+
seen.add(link.issueRefId);
|
|
625
|
+
out.push(link);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { getDb } from "./connection.ts";
|
|
2
|
-
import { TASK_SELECT, insertTaskEvent } from "./task-core.ts";
|
|
2
|
+
import { TASK_SELECT, insertTaskEvent, claimTaskWhere } from "./task-core.ts";
|
|
3
3
|
|
|
4
4
|
// On-demand automation tasks (targetMode=on_demand_agent) are bound to a single
|
|
5
5
|
// ephemeral agent spawned just for that task. When that agent's claim is released,
|
|
6
6
|
// the task must not return to a claimable pool that can never match it again.
|
|
7
7
|
export function settleSingleTargetOnDemandTasks(condition: string, params: any[], now: number, reason: string): void {
|
|
8
|
-
|
|
8
|
+
// Fenced to the claim lane (#834): on-demand settle must never resolve a deliverable Task.
|
|
9
|
+
const selectSql = `${TASK_SELECT} WHERE ${claimTaskWhere()} AND (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`;
|
|
9
10
|
const rows = getDb().query(selectSql).all(...params) as any[];
|
|
10
11
|
if (rows.length === 0) return;
|
|
11
12
|
getDb().query(
|
|
12
|
-
`UPDATE tasks SET status = 'done', claim_expires_at = NULL, updated_at = ?, last_seen_at = ? WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`
|
|
13
|
+
`UPDATE tasks SET status = 'done', claim_expires_at = NULL, updated_at = ?, last_seen_at = ? WHERE ${claimTaskWhere()} AND (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`
|
|
13
14
|
).run(now, now, ...params);
|
|
14
15
|
for (const row of rows) {
|
|
15
16
|
insertTaskEvent(row.id, {
|