@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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +62 -1
  3. package/package.json +1 -1
  4. package/src/agentmail/handlers.ts +87 -6
  5. package/src/be/db.ts +34 -2
  6. package/src/be/migrations/042_task_context_key.sql +13 -0
  7. package/src/commands/runner.ts +1 -0
  8. package/src/github/handlers.ts +42 -10
  9. package/src/gitlab/handlers.ts +29 -5
  10. package/src/hooks/hook.ts +4 -2
  11. package/src/http/core.ts +36 -26
  12. package/src/http/mcp-oauth.ts +132 -60
  13. package/src/http/mcp-servers.ts +5 -1
  14. package/src/http/schedules.ts +4 -2
  15. package/src/http/tasks.ts +4 -2
  16. package/src/linear/sync.ts +22 -10
  17. package/src/providers/claude-adapter.ts +51 -29
  18. package/src/scheduler/scheduler.ts +9 -10
  19. package/src/server.ts +2 -0
  20. package/src/slack/actions.ts +10 -9
  21. package/src/slack/assistant.ts +8 -4
  22. package/src/slack/handlers.ts +8 -3
  23. package/src/slack/thread-buffer.ts +61 -72
  24. package/src/tasks/additive-buffer.ts +152 -0
  25. package/src/tasks/additive-ingress.ts +125 -0
  26. package/src/tasks/context-key.ts +245 -0
  27. package/src/tasks/sibling-awareness.ts +144 -0
  28. package/src/tasks/sibling-block.ts +164 -0
  29. package/src/tests/additive-buffer.test.ts +186 -0
  30. package/src/tests/additive-ingress.test.ts +111 -0
  31. package/src/tests/claude-adapter.test.ts +143 -1
  32. package/src/tests/context-key-db.test.ts +87 -0
  33. package/src/tests/context-key.test.ts +173 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  36. package/src/tests/sibling-awareness-db.test.ts +172 -0
  37. package/src/tests/sibling-block.test.ts +232 -0
  38. package/src/tests/tool-annotations.test.ts +1 -0
  39. package/src/tools/slack-post.ts +10 -3
  40. package/src/tools/slack-start-thread.ts +123 -0
  41. package/src/tools/tool-config.ts +2 -1
  42. package/src/tools/update-profile.ts +5 -2
  43. package/src/types.ts +5 -0
  44. 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
+ }