@desplega.ai/agent-swarm 1.69.0 → 1.70.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/README.md +3 -3
- package/openapi.json +62 -1
- package/package.json +1 -1
- package/src/agentmail/handlers.ts +87 -6
- package/src/be/db.ts +34 -2
- package/src/be/migrations/042_task_context_key.sql +13 -0
- package/src/commands/runner.ts +1 -0
- package/src/github/handlers.ts +42 -10
- package/src/gitlab/handlers.ts +29 -5
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +36 -26
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/http/schedules.ts +4 -2
- package/src/http/tasks.ts +4 -2
- package/src/linear/sync.ts +22 -10
- package/src/providers/claude-adapter.ts +51 -29
- package/src/scheduler/scheduler.ts +9 -10
- package/src/server.ts +2 -0
- package/src/slack/actions.ts +10 -9
- package/src/slack/assistant.ts +8 -4
- package/src/slack/handlers.ts +8 -3
- package/src/slack/thread-buffer.ts +61 -72
- package/src/tasks/additive-buffer.ts +152 -0
- package/src/tasks/additive-ingress.ts +125 -0
- package/src/tasks/context-key.ts +245 -0
- package/src/tasks/sibling-awareness.ts +144 -0
- package/src/tasks/sibling-block.ts +164 -0
- package/src/tests/additive-buffer.test.ts +186 -0
- package/src/tests/additive-ingress.test.ts +111 -0
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/context-key-db.test.ts +87 -0
- package/src/tests/context-key.test.ts +173 -0
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/sibling-awareness-db.test.ts +172 -0
- package/src/tests/sibling-block.test.ts +232 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +5 -2
- package/src/types.ts +5 -0
- package/src/workflows/executors/agent-task.ts +21 -14
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-ingress task context keys.
|
|
3
|
+
*
|
|
4
|
+
* Every ingress surface (Slack, GitHub, GitLab, AgentMail, Linear, schedules,
|
|
5
|
+
* workflows, ...) builds a uniform string key that identifies the "context entity"
|
|
6
|
+
* a task belongs to. We persist the key on `agent_tasks.contextKey` so that a
|
|
7
|
+
* single indexed lookup can return all sibling tasks for a given entity.
|
|
8
|
+
*
|
|
9
|
+
* Key schema:
|
|
10
|
+
* task:slack:{channelId}:{threadTs}
|
|
11
|
+
* task:agentmail:{threadId}
|
|
12
|
+
* task:trackers:github:{owner}:{repo}:{issue|pr}:{number}
|
|
13
|
+
* task:trackers:gitlab:{projectId}:{mr|issue}:{iid}
|
|
14
|
+
* task:trackers:linear:{issueIdentifier} (e.g. DES-42 — case preserved)
|
|
15
|
+
* task:schedule:{scheduleId}
|
|
16
|
+
* task:workflow:{workflowRunId}
|
|
17
|
+
*
|
|
18
|
+
* Rules:
|
|
19
|
+
* - Fixed prefix tokens (`task`, family, sub-family, kind) are always lowercase.
|
|
20
|
+
* - Case is preserved inside identifier portions so Linear identifiers and
|
|
21
|
+
* GitHub repo slugs round-trip exactly.
|
|
22
|
+
* - `:` is the separator and forbidden in any embedded value. Callers must
|
|
23
|
+
* sanitize first; unsanitized inputs throw so bugs surface loudly at the
|
|
24
|
+
* ingress boundary rather than creating silent mis-keyed tasks.
|
|
25
|
+
* - `null`/`undefined`/empty values throw — a context key either exists
|
|
26
|
+
* fully or not at all.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const SEPARATOR = ":";
|
|
30
|
+
|
|
31
|
+
export type ContextKeyFamily = "slack" | "agentmail" | "trackers" | "schedule" | "workflow";
|
|
32
|
+
|
|
33
|
+
export type TrackerProvider = "github" | "gitlab" | "linear";
|
|
34
|
+
|
|
35
|
+
export type ParsedContextKey =
|
|
36
|
+
| { family: "slack"; parts: { channelId: string; threadTs: string } }
|
|
37
|
+
| { family: "agentmail"; parts: { threadId: string } }
|
|
38
|
+
| {
|
|
39
|
+
family: "trackers";
|
|
40
|
+
subFamily: "github";
|
|
41
|
+
parts: { owner: string; repo: string; kind: "issue" | "pr"; number: number };
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
family: "trackers";
|
|
45
|
+
subFamily: "gitlab";
|
|
46
|
+
parts: { projectId: string; kind: "mr" | "issue"; iid: number };
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
family: "trackers";
|
|
50
|
+
subFamily: "linear";
|
|
51
|
+
parts: { issueIdentifier: string };
|
|
52
|
+
}
|
|
53
|
+
| { family: "schedule"; parts: { scheduleId: string } }
|
|
54
|
+
| { family: "workflow"; parts: { workflowRunId: string } };
|
|
55
|
+
|
|
56
|
+
function assertSafePart(value: unknown, label: string): string {
|
|
57
|
+
if (value === null || value === undefined) {
|
|
58
|
+
throw new Error(`context-key: "${label}" is required`);
|
|
59
|
+
}
|
|
60
|
+
const str = typeof value === "string" ? value : String(value);
|
|
61
|
+
if (str.length === 0) {
|
|
62
|
+
throw new Error(`context-key: "${label}" must be non-empty`);
|
|
63
|
+
}
|
|
64
|
+
if (str.includes(SEPARATOR)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`context-key: "${label}" must not contain "${SEPARATOR}"; caller must sanitize (got ${JSON.stringify(str)})`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return str;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function slackContextKey(input: { channelId: string; threadTs: string }): string {
|
|
73
|
+
const channelId = assertSafePart(input.channelId, "channelId");
|
|
74
|
+
const threadTs = assertSafePart(input.threadTs, "threadTs");
|
|
75
|
+
return ["task", "slack", channelId, threadTs].join(SEPARATOR);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function agentmailContextKey(input: { threadId: string }): string {
|
|
79
|
+
const threadId = assertSafePart(input.threadId, "threadId");
|
|
80
|
+
return ["task", "agentmail", threadId].join(SEPARATOR);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function githubContextKey(input: {
|
|
84
|
+
owner: string;
|
|
85
|
+
repo: string;
|
|
86
|
+
kind: "issue" | "pr";
|
|
87
|
+
number: number;
|
|
88
|
+
}): string {
|
|
89
|
+
const owner = assertSafePart(input.owner, "owner");
|
|
90
|
+
const repo = assertSafePart(input.repo, "repo");
|
|
91
|
+
const kind = assertSafePart(input.kind, "kind").toLowerCase();
|
|
92
|
+
if (kind !== "issue" && kind !== "pr") {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`context-key: github "kind" must be "issue" or "pr" (got ${JSON.stringify(kind)})`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const number = assertSafePart(input.number, "number");
|
|
98
|
+
if (!/^\d+$/.test(number)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`context-key: github "number" must be a positive integer (got ${JSON.stringify(number)})`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return ["task", "trackers", "github", owner, repo, kind, number].join(SEPARATOR);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function gitlabContextKey(input: {
|
|
107
|
+
projectId: string | number;
|
|
108
|
+
kind: "mr" | "issue";
|
|
109
|
+
iid: number;
|
|
110
|
+
}): string {
|
|
111
|
+
const projectId = assertSafePart(input.projectId, "projectId");
|
|
112
|
+
const kind = assertSafePart(input.kind, "kind").toLowerCase();
|
|
113
|
+
if (kind !== "mr" && kind !== "issue") {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`context-key: gitlab "kind" must be "mr" or "issue" (got ${JSON.stringify(kind)})`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const iid = assertSafePart(input.iid, "iid");
|
|
119
|
+
if (!/^\d+$/.test(iid)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`context-key: gitlab "iid" must be a positive integer (got ${JSON.stringify(iid)})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return ["task", "trackers", "gitlab", projectId, kind, iid].join(SEPARATOR);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function linearContextKey(input: { issueIdentifier: string }): string {
|
|
128
|
+
const issueIdentifier = assertSafePart(input.issueIdentifier, "issueIdentifier");
|
|
129
|
+
return ["task", "trackers", "linear", issueIdentifier].join(SEPARATOR);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function scheduleContextKey(input: { scheduleId: string }): string {
|
|
133
|
+
const scheduleId = assertSafePart(input.scheduleId, "scheduleId");
|
|
134
|
+
return ["task", "schedule", scheduleId].join(SEPARATOR);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function workflowContextKey(input: { workflowRunId: string }): string {
|
|
138
|
+
const workflowRunId = assertSafePart(input.workflowRunId, "workflowRunId");
|
|
139
|
+
return ["task", "workflow", workflowRunId].join(SEPARATOR);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse a context key back into a structured form. Throws on malformed input.
|
|
144
|
+
* Useful for diagnostics and downstream routing; not used on the hot insert path.
|
|
145
|
+
*/
|
|
146
|
+
export function parseContextKey(key: string): ParsedContextKey {
|
|
147
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
148
|
+
throw new Error("context-key: key must be a non-empty string");
|
|
149
|
+
}
|
|
150
|
+
const parts = key.split(SEPARATOR);
|
|
151
|
+
if (parts.length < 3 || parts[0] !== "task") {
|
|
152
|
+
throw new Error(`context-key: malformed key (expected "task:..."): ${JSON.stringify(key)}`);
|
|
153
|
+
}
|
|
154
|
+
const family = parts[1];
|
|
155
|
+
switch (family) {
|
|
156
|
+
case "slack": {
|
|
157
|
+
if (parts.length !== 4) {
|
|
158
|
+
throw new Error(`context-key: malformed slack key: ${JSON.stringify(key)}`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
family: "slack",
|
|
162
|
+
parts: { channelId: parts[2] as string, threadTs: parts[3] as string },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
case "agentmail": {
|
|
166
|
+
if (parts.length !== 3) {
|
|
167
|
+
throw new Error(`context-key: malformed agentmail key: ${JSON.stringify(key)}`);
|
|
168
|
+
}
|
|
169
|
+
return { family: "agentmail", parts: { threadId: parts[2] as string } };
|
|
170
|
+
}
|
|
171
|
+
case "schedule": {
|
|
172
|
+
if (parts.length !== 3) {
|
|
173
|
+
throw new Error(`context-key: malformed schedule key: ${JSON.stringify(key)}`);
|
|
174
|
+
}
|
|
175
|
+
return { family: "schedule", parts: { scheduleId: parts[2] as string } };
|
|
176
|
+
}
|
|
177
|
+
case "workflow": {
|
|
178
|
+
if (parts.length !== 3) {
|
|
179
|
+
throw new Error(`context-key: malformed workflow key: ${JSON.stringify(key)}`);
|
|
180
|
+
}
|
|
181
|
+
return { family: "workflow", parts: { workflowRunId: parts[2] as string } };
|
|
182
|
+
}
|
|
183
|
+
case "trackers": {
|
|
184
|
+
const subFamily = parts[2];
|
|
185
|
+
if (subFamily === "github") {
|
|
186
|
+
if (parts.length !== 7) {
|
|
187
|
+
throw new Error(`context-key: malformed github key: ${JSON.stringify(key)}`);
|
|
188
|
+
}
|
|
189
|
+
const owner = parts[3] as string;
|
|
190
|
+
const repo = parts[4] as string;
|
|
191
|
+
const kind = parts[5];
|
|
192
|
+
const numberStr = parts[6] as string;
|
|
193
|
+
if (kind !== "issue" && kind !== "pr") {
|
|
194
|
+
throw new Error(`context-key: malformed github kind "${kind}": ${JSON.stringify(key)}`);
|
|
195
|
+
}
|
|
196
|
+
const number = Number.parseInt(numberStr, 10);
|
|
197
|
+
if (!Number.isFinite(number)) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`context-key: malformed github number "${numberStr}": ${JSON.stringify(key)}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
family: "trackers",
|
|
204
|
+
subFamily: "github",
|
|
205
|
+
parts: { owner, repo, kind, number },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (subFamily === "gitlab") {
|
|
209
|
+
if (parts.length !== 6) {
|
|
210
|
+
throw new Error(`context-key: malformed gitlab key: ${JSON.stringify(key)}`);
|
|
211
|
+
}
|
|
212
|
+
const projectId = parts[3] as string;
|
|
213
|
+
const kind = parts[4];
|
|
214
|
+
const iidStr = parts[5] as string;
|
|
215
|
+
if (kind !== "mr" && kind !== "issue") {
|
|
216
|
+
throw new Error(`context-key: malformed gitlab kind "${kind}": ${JSON.stringify(key)}`);
|
|
217
|
+
}
|
|
218
|
+
const iid = Number.parseInt(iidStr, 10);
|
|
219
|
+
if (!Number.isFinite(iid)) {
|
|
220
|
+
throw new Error(`context-key: malformed gitlab iid "${iidStr}": ${JSON.stringify(key)}`);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
family: "trackers",
|
|
224
|
+
subFamily: "gitlab",
|
|
225
|
+
parts: { projectId, kind, iid },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (subFamily === "linear") {
|
|
229
|
+
if (parts.length !== 4) {
|
|
230
|
+
throw new Error(`context-key: malformed linear key: ${JSON.stringify(key)}`);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
family: "trackers",
|
|
234
|
+
subFamily: "linear",
|
|
235
|
+
parts: { issueIdentifier: parts[3] as string },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
throw new Error(
|
|
239
|
+
`context-key: unknown trackers sub-family "${subFamily}": ${JSON.stringify(key)}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
default:
|
|
243
|
+
throw new Error(`context-key: unknown family "${family}": ${JSON.stringify(key)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB-backed orchestrator for cross-ingress sibling-task awareness (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the pure renderer/picker in `sibling-block.ts` with a single round-trip
|
|
5
|
+
* to the DB so any ingress can call ONE function before `createTaskExtended`
|
|
6
|
+
* to (a) prepend the sibling block to the task description and (b) auto-wire
|
|
7
|
+
* `parentTaskId` for same-agent resume.
|
|
8
|
+
*
|
|
9
|
+
* Lives on the server side — safe to import from any of `src/slack/`,
|
|
10
|
+
* `src/github/`, `src/gitlab/`, `src/agentmail/`, `src/linear/`,
|
|
11
|
+
* `src/scheduler/`, `src/http/`, `src/tools/`. Workers don't call this.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type CreateTaskOptions,
|
|
16
|
+
createTaskExtended,
|
|
17
|
+
getAgentById,
|
|
18
|
+
getInProgressTasksByContextKey,
|
|
19
|
+
} from "../be/db";
|
|
20
|
+
import type { AgentTask } from "../types";
|
|
21
|
+
import {
|
|
22
|
+
pickResumeParent,
|
|
23
|
+
prependSiblingBlock,
|
|
24
|
+
type SiblingTaskInfo,
|
|
25
|
+
stripSiblingBlock,
|
|
26
|
+
} from "./sibling-block";
|
|
27
|
+
|
|
28
|
+
export type ApplySiblingAwarenessInput = {
|
|
29
|
+
description: string;
|
|
30
|
+
contextKey: string;
|
|
31
|
+
// The agent that will own the new task. When provided, sibling auto-wiring
|
|
32
|
+
// for `parentTaskId` only fires if a sibling on the same agent exists.
|
|
33
|
+
// When null/undefined, no parent is wired (resume semantics undefined).
|
|
34
|
+
currentAgentId?: string | null;
|
|
35
|
+
// Optional override for "now" — used by tests for deterministic output.
|
|
36
|
+
now?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ApplySiblingAwarenessResult = {
|
|
40
|
+
// The description with the sibling block prepended (or unchanged if no
|
|
41
|
+
// siblings were found).
|
|
42
|
+
description: string;
|
|
43
|
+
// The id of the sibling that should be wired as `parentTaskId`, or
|
|
44
|
+
// undefined when no eligible sibling exists. Callers MUST pass this through
|
|
45
|
+
// to `createTaskExtended` to get session resume.
|
|
46
|
+
parentTaskId?: string;
|
|
47
|
+
// The siblings the orchestrator considered. Useful for callers that want to
|
|
48
|
+
// log / instrument; safe to ignore.
|
|
49
|
+
siblings: SiblingTaskInfo[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function toSiblingTaskInfo(task: AgentTask): SiblingTaskInfo {
|
|
53
|
+
const agent = task.agentId ? getAgentById(task.agentId) : null;
|
|
54
|
+
return {
|
|
55
|
+
id: task.id,
|
|
56
|
+
status: task.status,
|
|
57
|
+
agentId: task.agentId,
|
|
58
|
+
agentName: agent?.name ?? null,
|
|
59
|
+
description: stripSiblingBlock(task.task),
|
|
60
|
+
updatedAt: task.lastUpdatedAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Look up siblings for a given contextKey, render the prompt block, and
|
|
66
|
+
* return the (potentially) modified description plus the parent task id to
|
|
67
|
+
* wire. Safe to call when no siblings exist — returns the description
|
|
68
|
+
* unchanged and `parentTaskId: undefined`.
|
|
69
|
+
*
|
|
70
|
+
* Callers should NOT pass `parentTaskId` to `createTaskExtended` separately —
|
|
71
|
+
* the returned value already takes precedence. (If both are passed, callers
|
|
72
|
+
* are responsible for deciding which one wins.)
|
|
73
|
+
*/
|
|
74
|
+
export function applySiblingAwareness(
|
|
75
|
+
input: ApplySiblingAwarenessInput,
|
|
76
|
+
): ApplySiblingAwarenessResult {
|
|
77
|
+
const { description, contextKey, currentAgentId } = input;
|
|
78
|
+
if (!contextKey) {
|
|
79
|
+
return { description, siblings: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const tasks = getInProgressTasksByContextKey(contextKey);
|
|
83
|
+
if (tasks.length === 0) {
|
|
84
|
+
return { description, siblings: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const siblings = tasks.map(toSiblingTaskInfo);
|
|
88
|
+
const parent = pickResumeParent(siblings, currentAgentId ?? null);
|
|
89
|
+
const newDescription = prependSiblingBlock(description, contextKey, siblings, input.now);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
description: newDescription,
|
|
93
|
+
parentTaskId: parent?.id,
|
|
94
|
+
siblings,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convenience wrapper that applies sibling-awareness to a `(description,
|
|
100
|
+
* options)` pair ready to be passed to `createTaskExtended`.
|
|
101
|
+
*
|
|
102
|
+
* Semantics:
|
|
103
|
+
* - `contextKey` is read from `options.contextKey` — callers must set it
|
|
104
|
+
* before calling this helper (Phase 1 already does that at every ingress).
|
|
105
|
+
* - If `options.parentTaskId` is already set, it is respected and NOT
|
|
106
|
+
* overridden by sibling-awareness. This means any ingress that has its
|
|
107
|
+
* own parent-picking logic (e.g. Slack lead handler) keeps working.
|
|
108
|
+
* - The returned `options` object is a shallow copy; callers may pass it
|
|
109
|
+
* directly to `createTaskExtended`.
|
|
110
|
+
*/
|
|
111
|
+
export function withSiblingAwareness(
|
|
112
|
+
description: string,
|
|
113
|
+
options: CreateTaskOptions,
|
|
114
|
+
): { description: string; options: CreateTaskOptions } {
|
|
115
|
+
const contextKey = options.contextKey;
|
|
116
|
+
if (!contextKey) {
|
|
117
|
+
return { description, options };
|
|
118
|
+
}
|
|
119
|
+
const result = applySiblingAwareness({
|
|
120
|
+
description,
|
|
121
|
+
contextKey,
|
|
122
|
+
currentAgentId: options.agentId ?? null,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
description: result.description,
|
|
126
|
+
options: {
|
|
127
|
+
...options,
|
|
128
|
+
parentTaskId: options.parentTaskId ?? result.parentTaskId,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Drop-in replacement for `createTaskExtended` that applies sibling-awareness
|
|
135
|
+
* first. Use this from every ingress that has a `contextKey` so cross-ingress
|
|
136
|
+
* sibling coordination is uniform without duplicating the wrapper boilerplate.
|
|
137
|
+
*/
|
|
138
|
+
export function createTaskWithSiblingAwareness(
|
|
139
|
+
description: string,
|
|
140
|
+
options: CreateTaskOptions,
|
|
141
|
+
): AgentTask {
|
|
142
|
+
const { description: d, options: o } = withSiblingAwareness(description, options);
|
|
143
|
+
return createTaskExtended(d, o);
|
|
144
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rendering + selection helpers for cross-ingress sibling-task awareness.
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 of the cross-ingress sibling-task awareness initiative. The reader
|
|
5
|
+
* side: when an ingress is about to create a task and other tasks already
|
|
6
|
+
* share the same `contextKey`, we (a) prepend a `<sibling_tasks_in_progress>`
|
|
7
|
+
* block to the new task's description so the worker knows about the
|
|
8
|
+
* concurrent work, and (b) optionally wire `parentTaskId` to the most
|
|
9
|
+
* relevant sibling so Claude-Code session resume kicks in.
|
|
10
|
+
*
|
|
11
|
+
* This module is intentionally dependency-free — no DB calls, no I/O — so it
|
|
12
|
+
* is trivial to unit-test and safe to call from any ingress.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type SiblingTaskInfo = {
|
|
16
|
+
id: string;
|
|
17
|
+
status: string;
|
|
18
|
+
agentId: string | null;
|
|
19
|
+
agentName: string | null;
|
|
20
|
+
description: string;
|
|
21
|
+
// Most recent change timestamp (ISO string). Used for relative-time render
|
|
22
|
+
// and for picking the "best" sibling to wire as parent.
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DESCRIPTION_TRUNCATE = 200;
|
|
27
|
+
|
|
28
|
+
// Status priority for picking the resume parent. Higher number wins.
|
|
29
|
+
// in_progress > pending > offered > paused (per task spec, research §3.2).
|
|
30
|
+
const STATUS_PRIORITY: Record<string, number> = {
|
|
31
|
+
in_progress: 4,
|
|
32
|
+
pending: 3,
|
|
33
|
+
offered: 2,
|
|
34
|
+
paused: 1,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove a previously-prepended sibling block (if any) from a task
|
|
39
|
+
* description. Called before rendering a sibling into a *new* block so we
|
|
40
|
+
* don't end up nesting blocks recursively — siblings show their ORIGINAL
|
|
41
|
+
* user-facing intent, not the inherited sibling-awareness preamble from when
|
|
42
|
+
* they were created.
|
|
43
|
+
*/
|
|
44
|
+
export function stripSiblingBlock(description: string): string {
|
|
45
|
+
if (typeof description !== "string") return "";
|
|
46
|
+
const start = description.indexOf("<sibling_tasks_in_progress>");
|
|
47
|
+
if (start === -1) return description;
|
|
48
|
+
const closeTag = "</sibling_tasks_in_progress>";
|
|
49
|
+
const end = description.indexOf(closeTag, start);
|
|
50
|
+
if (end === -1) return description;
|
|
51
|
+
// Drop block + any immediately following whitespace (blank line separator).
|
|
52
|
+
const after = description.slice(end + closeTag.length).replace(/^\s+/, "");
|
|
53
|
+
const before = description.slice(0, start).replace(/\s+$/, "");
|
|
54
|
+
return before ? `${before}\n\n${after}` : after;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function truncateForBlock(s: string, max: number = DESCRIPTION_TRUNCATE): string {
|
|
58
|
+
if (typeof s !== "string") return "";
|
|
59
|
+
// Collapse any embedded newlines so each sibling stays on one rendered line
|
|
60
|
+
// (the description follows the bullet on its own continuation line).
|
|
61
|
+
const flattened = s.replace(/\s+/g, " ").trim();
|
|
62
|
+
if (flattened.length <= max) return flattened;
|
|
63
|
+
return `${flattened.slice(0, max).trimEnd()}…`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatRelativeTime(then: string | number | Date, now: number = Date.now()): string {
|
|
67
|
+
const t = then instanceof Date ? then.getTime() : new Date(then).getTime();
|
|
68
|
+
if (!Number.isFinite(t)) return "unknown time";
|
|
69
|
+
const diffMs = Math.max(0, now - t);
|
|
70
|
+
const sec = Math.floor(diffMs / 1000);
|
|
71
|
+
if (sec < 60) return `${sec}s`;
|
|
72
|
+
const min = Math.floor(sec / 60);
|
|
73
|
+
if (min < 60) return `${min}m`;
|
|
74
|
+
const hr = Math.floor(min / 60);
|
|
75
|
+
if (hr < 24) return `${hr}h`;
|
|
76
|
+
const day = Math.floor(hr / 24);
|
|
77
|
+
return `${day}d`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render the sibling-tasks block. Returns "" when there are no siblings so
|
|
82
|
+
* callers can unconditionally prepend the result.
|
|
83
|
+
*/
|
|
84
|
+
export function renderSiblingBlock(
|
|
85
|
+
contextKey: string,
|
|
86
|
+
siblings: SiblingTaskInfo[],
|
|
87
|
+
now: number = Date.now(),
|
|
88
|
+
): string {
|
|
89
|
+
if (!siblings || siblings.length === 0) return "";
|
|
90
|
+
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
lines.push("<sibling_tasks_in_progress>");
|
|
93
|
+
lines.push(
|
|
94
|
+
`The following tasks are already running in the same context (contextKey: ${contextKey}). The user has submitted new input while they were in flight — coordinate with them, do not duplicate:`,
|
|
95
|
+
);
|
|
96
|
+
lines.push("");
|
|
97
|
+
for (const s of siblings) {
|
|
98
|
+
const agentLabel = s.agentName
|
|
99
|
+
? `agent:${s.agentName}`
|
|
100
|
+
: s.agentId
|
|
101
|
+
? `agent:${s.agentId}`
|
|
102
|
+
: "agent:unassigned";
|
|
103
|
+
const rel = formatRelativeTime(s.updatedAt, now);
|
|
104
|
+
lines.push(`- [${s.status}] task:${s.id} — ${agentLabel} — started ${rel} ago`);
|
|
105
|
+
lines.push(` "${truncateForBlock(s.description)}"`);
|
|
106
|
+
}
|
|
107
|
+
lines.push("</sibling_tasks_in_progress>");
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pick the sibling that should be wired as `parentTaskId` so Claude-Code
|
|
113
|
+
* session resume picks up the conversation. Returns `null` when no sibling
|
|
114
|
+
* is eligible.
|
|
115
|
+
*
|
|
116
|
+
* Eligibility rules (research §3.2 + task spec):
|
|
117
|
+
* - Sibling must be assigned to the same agent as the incoming task.
|
|
118
|
+
* Cross-agent resume doesn't make sense — different worker, different
|
|
119
|
+
* filesystem, different session state.
|
|
120
|
+
* - Among eligible siblings, prefer higher status priority
|
|
121
|
+
* (in_progress > pending > offered > paused), then most-recently-updated.
|
|
122
|
+
*
|
|
123
|
+
* If `currentAgentId` is null/undefined, no wiring happens — we don't know
|
|
124
|
+
* which worker will pick up the task, so resume semantics are undefined.
|
|
125
|
+
*/
|
|
126
|
+
export function pickResumeParent<
|
|
127
|
+
T extends Pick<SiblingTaskInfo, "id" | "status" | "agentId" | "updatedAt">,
|
|
128
|
+
>(siblings: T[], currentAgentId: string | null | undefined): T | null {
|
|
129
|
+
if (!currentAgentId) return null;
|
|
130
|
+
if (!siblings || siblings.length === 0) return null;
|
|
131
|
+
|
|
132
|
+
const eligible = siblings.filter((s) => s.agentId && s.agentId === currentAgentId);
|
|
133
|
+
if (eligible.length === 0) return null;
|
|
134
|
+
|
|
135
|
+
let best: T | null = null;
|
|
136
|
+
let bestPriority = -1;
|
|
137
|
+
let bestTime = -1;
|
|
138
|
+
for (const s of eligible) {
|
|
139
|
+
const priority = STATUS_PRIORITY[s.status] ?? 0;
|
|
140
|
+
const time = new Date(s.updatedAt).getTime();
|
|
141
|
+
const timeFinite = Number.isFinite(time) ? time : 0;
|
|
142
|
+
if (priority > bestPriority || (priority === bestPriority && timeFinite > bestTime)) {
|
|
143
|
+
best = s;
|
|
144
|
+
bestPriority = priority;
|
|
145
|
+
bestTime = timeFinite;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return best;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convenience: prepend the sibling block to a task description, with a blank
|
|
153
|
+
* line separator. Returns `description` unchanged when there are no siblings.
|
|
154
|
+
*/
|
|
155
|
+
export function prependSiblingBlock(
|
|
156
|
+
description: string,
|
|
157
|
+
contextKey: string,
|
|
158
|
+
siblings: SiblingTaskInfo[],
|
|
159
|
+
now: number = Date.now(),
|
|
160
|
+
): string {
|
|
161
|
+
const block = renderSiblingBlock(contextKey, siblings, now);
|
|
162
|
+
if (!block) return description;
|
|
163
|
+
return `${block}\n\n${description}`;
|
|
164
|
+
}
|