@desplega.ai/agent-swarm 1.69.0 → 1.69.1

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Generic additive/debounce buffer, keyed on `contextKey` (or any string key).
3
+ *
4
+ * Phase 2 of cross-ingress sibling-task awareness — research §5.
5
+ *
6
+ * Extracted from `src/slack/thread-buffer.ts` so any ingress (AgentMail,
7
+ * GitHub/GitLab issue comments, Linear comments — NOT schedule/workflow) can
8
+ * coalesce rapid follow-up inputs into a single task.
9
+ *
10
+ * The primitive is a factory: each caller owns its own buffer registry with
11
+ * its own flush callback. That keeps flush semantics ingress-specific while
12
+ * the debounce / append / count plumbing is shared.
13
+ *
14
+ * Behavior:
15
+ * - `enqueue(key, item)` appends `item` to the in-memory buffer for `key`.
16
+ * - The debounce timer is reset on every append.
17
+ * - When the timer fires, `onFlush(items, key, reason="timer")` is called
18
+ * with the accumulated list.
19
+ * - `instantFlush(key)` fires the callback immediately with
20
+ * `reason="manual"` and clears the buffer.
21
+ * - `cancel(key)` drops the buffer without flushing (used by ingress when
22
+ * the underlying context becomes irrelevant — e.g. user cancels).
23
+ *
24
+ * Concurrency: single-process, single-event-loop. No cross-instance locking.
25
+ * Flush callbacks run sequentially per key (the buffer is cleared BEFORE the
26
+ * callback fires, so re-enqueues during `onFlush` create a fresh buffer).
27
+ */
28
+
29
+ export type BufferFlushReason = "timer" | "manual";
30
+
31
+ export interface AdditiveBufferOptions<T> {
32
+ /**
33
+ * Debounce timeout. Resets on every append. When it elapses without new
34
+ * appends, `onFlush` is called.
35
+ */
36
+ timeoutMs: number;
37
+ /**
38
+ * Called with the accumulated items when the buffer flushes. Receives the
39
+ * `contextKey` the buffer was created under and a `reason` indicating
40
+ * whether this was a timer-driven flush or a manual (`instantFlush`) one.
41
+ *
42
+ * Errors thrown here are caught and logged — they do NOT re-enter the
43
+ * buffer, because the buffer has already been cleared by the time `onFlush`
44
+ * is called. Callers that need retry semantics must implement them inside
45
+ * `onFlush`.
46
+ */
47
+ onFlush: (items: T[], contextKey: string, reason: BufferFlushReason) => void | Promise<void>;
48
+ /**
49
+ * Optional label, used in log lines (`[buffer:${label}]`). Helps when
50
+ * multiple buffers exist in the same process.
51
+ */
52
+ label?: string;
53
+ }
54
+
55
+ export interface AdditiveBuffer<T> {
56
+ /** Append an item, creating the buffer if needed. Resets the debounce timer. */
57
+ enqueue(contextKey: string, item: T): void;
58
+ /** True when a buffer exists for this key (i.e. at least one item is queued). */
59
+ isBuffered(contextKey: string): boolean;
60
+ /** Number of items currently queued for this key, or 0. */
61
+ count(contextKey: string): number;
62
+ /** Flush immediately with `reason="manual"`. No-op when no buffer exists. */
63
+ instantFlush(contextKey: string): Promise<void>;
64
+ /** Drop the buffer without flushing. No-op when no buffer exists. */
65
+ cancel(contextKey: string): boolean;
66
+ /** For tests / diagnostics. */
67
+ keys(): string[];
68
+ }
69
+
70
+ interface BufferEntry<T> {
71
+ items: T[];
72
+ timer: ReturnType<typeof setTimeout>;
73
+ }
74
+
75
+ export function createAdditiveBuffer<T>(options: AdditiveBufferOptions<T>): AdditiveBuffer<T> {
76
+ const { timeoutMs, onFlush, label } = options;
77
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
78
+ throw new Error(`additive-buffer: timeoutMs must be a positive number, got ${timeoutMs}`);
79
+ }
80
+
81
+ const buffers = new Map<string, BufferEntry<T>>();
82
+ const prefix = label ? `[buffer:${label}]` : "[buffer]";
83
+
84
+ function scheduleFlush(key: string) {
85
+ return setTimeout(() => {
86
+ void doFlush(key, "timer");
87
+ }, timeoutMs);
88
+ }
89
+
90
+ async function doFlush(key: string, reason: BufferFlushReason): Promise<void> {
91
+ const entry = buffers.get(key);
92
+ if (!entry || entry.items.length === 0) {
93
+ buffers.delete(key);
94
+ return;
95
+ }
96
+ clearTimeout(entry.timer);
97
+ buffers.delete(key);
98
+ try {
99
+ await onFlush(entry.items, key, reason);
100
+ } catch (error) {
101
+ console.error(`${prefix} onFlush threw for key=${key}:`, error);
102
+ }
103
+ }
104
+
105
+ return {
106
+ enqueue(contextKey: string, item: T): void {
107
+ if (!contextKey) {
108
+ throw new Error("additive-buffer: contextKey is required");
109
+ }
110
+ const existing = buffers.get(contextKey);
111
+ if (existing) {
112
+ clearTimeout(existing.timer);
113
+ existing.items.push(item);
114
+ existing.timer = scheduleFlush(contextKey);
115
+ console.log(
116
+ `${prefix} append: ${contextKey} (${existing.items.length} items, timer reset to ${timeoutMs}ms)`,
117
+ );
118
+ } else {
119
+ const entry: BufferEntry<T> = {
120
+ items: [item],
121
+ timer: scheduleFlush(contextKey),
122
+ };
123
+ buffers.set(contextKey, entry);
124
+ console.log(`${prefix} created: ${contextKey} (timer set to ${timeoutMs}ms)`);
125
+ }
126
+ },
127
+
128
+ isBuffered(contextKey: string): boolean {
129
+ return buffers.has(contextKey);
130
+ },
131
+
132
+ count(contextKey: string): number {
133
+ return buffers.get(contextKey)?.items.length ?? 0;
134
+ },
135
+
136
+ instantFlush(contextKey: string): Promise<void> {
137
+ return doFlush(contextKey, "manual");
138
+ },
139
+
140
+ cancel(contextKey: string): boolean {
141
+ const entry = buffers.get(contextKey);
142
+ if (!entry) return false;
143
+ clearTimeout(entry.timer);
144
+ buffers.delete(contextKey);
145
+ return true;
146
+ },
147
+
148
+ keys(): string[] {
149
+ return Array.from(buffers.keys());
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Generic "additive ingress" helper for Phase 2 cross-ingress sibling-awareness.
3
+ *
4
+ * Wraps `createAdditiveBuffer` with the typical pattern used by comment-style
5
+ * ingress points (AgentMail threads, GitHub/GitLab issue comments, Linear
6
+ * comments): when a sibling task is already in flight for the same contextKey,
7
+ * debounce rapid follow-up inputs into a SINGLE follow-up task instead of
8
+ * spawning N tasks.
9
+ *
10
+ * This generalizes the Slack `ADDITIVE_SLACK` buffer — Slack's own buffer stays
11
+ * as-is to preserve exact behaviour; other ingress points opt in via env flags.
12
+ *
13
+ * Default: opt-in (env flag unset => all calls are no-ops; caller proceeds to
14
+ * create a task normally).
15
+ */
16
+
17
+ import { type AdditiveBuffer, createAdditiveBuffer } from "./additive-buffer";
18
+
19
+ export type IngressFlushReason = "timer" | "manual";
20
+
21
+ export interface IngressBufferItem<T> {
22
+ payload: T;
23
+ enqueuedAt: number;
24
+ }
25
+
26
+ export interface IngressBufferOptions<T> {
27
+ /** Short identifier used in logs, e.g. "agentmail", "github-issue-comment". */
28
+ source: string;
29
+ /**
30
+ * Env flag name (e.g. `"ADDITIVE_AGENTMAIL"`). When the flag resolves to
31
+ * `"true"` the buffer is enabled; otherwise `maybeBuffer()` is a no-op.
32
+ */
33
+ envFlag: string;
34
+ /** Debounce timeout in ms. Reset on every enqueue. Default: 10000ms. */
35
+ timeoutMs?: number;
36
+ /**
37
+ * Called when the buffer flushes (timer expiry OR manual). Receives the
38
+ * payloads in arrival order, the contextKey, and the reason. Errors thrown
39
+ * here are logged and swallowed.
40
+ */
41
+ onFlush: (items: T[], contextKey: string, reason: IngressFlushReason) => Promise<void> | void;
42
+ }
43
+
44
+ export interface IngressBuffer<T> {
45
+ /**
46
+ * `true` when the env flag was set to `"true"` at construction time.
47
+ * Callers should still guard with `maybeBuffer` — this is informational.
48
+ */
49
+ enabled: boolean;
50
+ /**
51
+ * Attempt to buffer an input. Returns `true` when the item was buffered
52
+ * (caller MUST NOT create a task), `false` otherwise (caller proceeds).
53
+ *
54
+ * Params:
55
+ * - `contextKey` — uniform sibling key from Phase 1. Empty string disables.
56
+ * - `siblingInFlight` — whether the caller already knows a sibling exists
57
+ * for `contextKey`. Callers typically pass the boolean from
58
+ * `getInProgressTasksByContextKey(contextKey).length > 0`.
59
+ * - `payload` — the item to buffer.
60
+ */
61
+ maybeBuffer(contextKey: string, siblingInFlight: boolean, payload: T): boolean;
62
+ /** True if the key currently has a pending buffer (for debugging/tests). */
63
+ isBuffered(contextKey: string): boolean;
64
+ /** Count of items in the buffer for `contextKey`. */
65
+ count(contextKey: string): number;
66
+ /** Flush immediately, cancelling the debounce timer. */
67
+ instantFlush(contextKey: string): Promise<void>;
68
+ /** Drop buffered items without flushing. */
69
+ cancel(contextKey: string): void;
70
+ /** Raw underlying buffer — escape hatch for tests. */
71
+ _buffer: AdditiveBuffer<T>;
72
+ }
73
+
74
+ /**
75
+ * Read the env flag value lazily so tests can toggle it without re-importing.
76
+ */
77
+ function envEnabled(flag: string): boolean {
78
+ return process.env[flag] === "true";
79
+ }
80
+
81
+ /**
82
+ * Create an ingress buffer. The wrapper records the env flag at construction
83
+ * time (not per-call) so behaviour is stable across a process run.
84
+ *
85
+ * Consumers typically:
86
+ * 1. Look up siblings for the contextKey (`getInProgressTasksByContextKey`).
87
+ * 2. Call `buffer.maybeBuffer(contextKey, siblings.length > 0, payload)`.
88
+ * 3. If `true`, stop — buffered. Otherwise, proceed to `createTaskWithSiblingAwareness`.
89
+ */
90
+ export function createIngressBuffer<T>(opts: IngressBufferOptions<T>): IngressBuffer<T> {
91
+ const enabled = envEnabled(opts.envFlag);
92
+ const timeoutMs = opts.timeoutMs ?? 10_000;
93
+
94
+ const buffer = createAdditiveBuffer<T>({
95
+ timeoutMs,
96
+ label: opts.source,
97
+ onFlush: async (items, key, reason) => {
98
+ await opts.onFlush(items, key, reason);
99
+ },
100
+ });
101
+
102
+ return {
103
+ enabled,
104
+ maybeBuffer(contextKey, siblingInFlight, payload) {
105
+ if (!enabled) return false;
106
+ if (!contextKey) return false;
107
+ if (!siblingInFlight) return false;
108
+ buffer.enqueue(contextKey, payload);
109
+ return true;
110
+ },
111
+ isBuffered(contextKey) {
112
+ return buffer.isBuffered(contextKey);
113
+ },
114
+ count(contextKey) {
115
+ return buffer.count(contextKey);
116
+ },
117
+ instantFlush(contextKey) {
118
+ return buffer.instantFlush(contextKey);
119
+ },
120
+ cancel(contextKey) {
121
+ buffer.cancel(contextKey);
122
+ },
123
+ _buffer: buffer,
124
+ };
125
+ }
@@ -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
+ }