@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.
- package/README.md +3 -3
- package/openapi.json +4 -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/github/handlers.ts +42 -10
- package/src/gitlab/handlers.ts +29 -5
- 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 +1 -0
- package/src/scheduler/scheduler.ts +9 -10
- 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/context-key-db.test.ts +87 -0
- package/src/tests/context-key.test.ts +173 -0
- package/src/tests/sibling-awareness-db.test.ts +172 -0
- package/src/tests/sibling-block.test.ts +232 -0
- package/src/types.ts +5 -0
- package/src/workflows/executors/agent-task.ts +21 -14
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createAdditiveBuffer } from "../tasks/additive-buffer";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper: wait for a timer-based flush. We use tight timeouts (10-30ms) so
|
|
6
|
+
* tests stay fast — these operate on Bun's event loop, not real time.
|
|
7
|
+
*/
|
|
8
|
+
function sleep(ms: number): Promise<void> {
|
|
9
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("createAdditiveBuffer", () => {
|
|
13
|
+
test("rejects non-positive timeoutMs", () => {
|
|
14
|
+
expect(() => createAdditiveBuffer({ timeoutMs: 0, onFlush: () => {} })).toThrow(
|
|
15
|
+
/positive number/,
|
|
16
|
+
);
|
|
17
|
+
expect(() => createAdditiveBuffer({ timeoutMs: -1, onFlush: () => {} })).toThrow();
|
|
18
|
+
// biome-ignore lint/suspicious/noExplicitAny: type-guard test
|
|
19
|
+
expect(() => createAdditiveBuffer({ timeoutMs: NaN as any, onFlush: () => {} })).toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("enqueue without existing buffer creates one with timer", () => {
|
|
23
|
+
let flushed: number[] | null = null;
|
|
24
|
+
const buf = createAdditiveBuffer<number>({
|
|
25
|
+
timeoutMs: 10_000,
|
|
26
|
+
onFlush: (items) => {
|
|
27
|
+
flushed = [...items];
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
buf.enqueue("k1", 1);
|
|
31
|
+
expect(buf.isBuffered("k1")).toBe(true);
|
|
32
|
+
expect(buf.count("k1")).toBe(1);
|
|
33
|
+
expect(flushed).toBeNull();
|
|
34
|
+
buf.cancel("k1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("three rapid enqueues coalesce into one flush", async () => {
|
|
38
|
+
const flushes: string[][] = [];
|
|
39
|
+
const buf = createAdditiveBuffer<string>({
|
|
40
|
+
timeoutMs: 20,
|
|
41
|
+
onFlush: (items) => {
|
|
42
|
+
flushes.push([...items]);
|
|
43
|
+
},
|
|
44
|
+
label: "test-coalesce",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
buf.enqueue("k", "a");
|
|
48
|
+
await sleep(5);
|
|
49
|
+
buf.enqueue("k", "b");
|
|
50
|
+
await sleep(5);
|
|
51
|
+
buf.enqueue("k", "c");
|
|
52
|
+
expect(buf.count("k")).toBe(3);
|
|
53
|
+
|
|
54
|
+
// Wait for debounce to elapse
|
|
55
|
+
await sleep(50);
|
|
56
|
+
|
|
57
|
+
expect(flushes.length).toBe(1);
|
|
58
|
+
expect(flushes[0]).toEqual(["a", "b", "c"]);
|
|
59
|
+
expect(buf.isBuffered("k")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("enqueue resets the timer", async () => {
|
|
63
|
+
const flushes: number[][] = [];
|
|
64
|
+
const buf = createAdditiveBuffer<number>({
|
|
65
|
+
timeoutMs: 30,
|
|
66
|
+
onFlush: (items) => {
|
|
67
|
+
flushes.push([...items]);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
buf.enqueue("k", 1);
|
|
72
|
+
await sleep(20); // 20 < 30, timer would have NOT fired
|
|
73
|
+
buf.enqueue("k", 2); // resets
|
|
74
|
+
await sleep(20); // another 20 < 30
|
|
75
|
+
buf.enqueue("k", 3); // resets again
|
|
76
|
+
|
|
77
|
+
expect(flushes.length).toBe(0);
|
|
78
|
+
await sleep(60);
|
|
79
|
+
expect(flushes.length).toBe(1);
|
|
80
|
+
expect(flushes[0]).toEqual([1, 2, 3]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("instantFlush fires immediately with reason='manual'", async () => {
|
|
84
|
+
let seenReason: string | null = null;
|
|
85
|
+
const buf = createAdditiveBuffer<number>({
|
|
86
|
+
timeoutMs: 10_000,
|
|
87
|
+
onFlush: (_items, _key, reason) => {
|
|
88
|
+
seenReason = reason;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
buf.enqueue("k", 1);
|
|
92
|
+
await buf.instantFlush("k");
|
|
93
|
+
expect(seenReason).toBe("manual");
|
|
94
|
+
expect(buf.isBuffered("k")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("timer flush reports reason='timer'", async () => {
|
|
98
|
+
let seenReason: string | null = null;
|
|
99
|
+
const buf = createAdditiveBuffer<number>({
|
|
100
|
+
timeoutMs: 10,
|
|
101
|
+
onFlush: (_items, _key, reason) => {
|
|
102
|
+
seenReason = reason;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
buf.enqueue("k", 1);
|
|
106
|
+
await sleep(40);
|
|
107
|
+
expect(seenReason).toBe("timer");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("instantFlush on unknown key is a no-op", async () => {
|
|
111
|
+
let called = false;
|
|
112
|
+
const buf = createAdditiveBuffer<number>({
|
|
113
|
+
timeoutMs: 10_000,
|
|
114
|
+
onFlush: () => {
|
|
115
|
+
called = true;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
await buf.instantFlush("nope");
|
|
119
|
+
expect(called).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("cancel drops the buffer without flushing", async () => {
|
|
123
|
+
let called = false;
|
|
124
|
+
const buf = createAdditiveBuffer<number>({
|
|
125
|
+
timeoutMs: 20,
|
|
126
|
+
onFlush: () => {
|
|
127
|
+
called = true;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
buf.enqueue("k", 1);
|
|
131
|
+
expect(buf.cancel("k")).toBe(true);
|
|
132
|
+
expect(buf.isBuffered("k")).toBe(false);
|
|
133
|
+
await sleep(50);
|
|
134
|
+
expect(called).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("cancel on unknown key returns false", () => {
|
|
138
|
+
const buf = createAdditiveBuffer<number>({ timeoutMs: 10_000, onFlush: () => {} });
|
|
139
|
+
expect(buf.cancel("nope")).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("enqueue rejects empty contextKey", () => {
|
|
143
|
+
const buf = createAdditiveBuffer<number>({ timeoutMs: 10_000, onFlush: () => {} });
|
|
144
|
+
expect(() => buf.enqueue("", 1)).toThrow(/contextKey/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("onFlush errors are swallowed (logged, buffer still clears)", async () => {
|
|
148
|
+
const buf = createAdditiveBuffer<number>({
|
|
149
|
+
timeoutMs: 10,
|
|
150
|
+
onFlush: () => {
|
|
151
|
+
throw new Error("boom");
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
buf.enqueue("k", 1);
|
|
155
|
+
await sleep(40);
|
|
156
|
+
expect(buf.isBuffered("k")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("buffers are independent across keys", async () => {
|
|
160
|
+
const flushes: Record<string, number[][]> = {};
|
|
161
|
+
const buf = createAdditiveBuffer<number>({
|
|
162
|
+
timeoutMs: 20,
|
|
163
|
+
onFlush: (items, key) => {
|
|
164
|
+
const arr = flushes[key] ?? [];
|
|
165
|
+
arr.push([...items]);
|
|
166
|
+
flushes[key] = arr;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
buf.enqueue("a", 1);
|
|
170
|
+
buf.enqueue("b", 10);
|
|
171
|
+
buf.enqueue("a", 2);
|
|
172
|
+
await sleep(50);
|
|
173
|
+
expect(flushes.a).toEqual([[1, 2]]);
|
|
174
|
+
expect(flushes.b).toEqual([[10]]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("keys() returns active buffer keys", () => {
|
|
178
|
+
const buf = createAdditiveBuffer<number>({ timeoutMs: 10_000, onFlush: () => {} });
|
|
179
|
+
buf.enqueue("a", 1);
|
|
180
|
+
buf.enqueue("b", 2);
|
|
181
|
+
expect(new Set(buf.keys())).toEqual(new Set(["a", "b"]));
|
|
182
|
+
buf.cancel("a");
|
|
183
|
+
expect(buf.keys()).toEqual(["b"]);
|
|
184
|
+
buf.cancel("b");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createIngressBuffer } from "../tasks/additive-ingress";
|
|
3
|
+
|
|
4
|
+
function sleep(ms: number): Promise<void> {
|
|
5
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const FLAG = "TEST_ADDITIVE_INGRESS";
|
|
9
|
+
|
|
10
|
+
describe("createIngressBuffer", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
process.env[FLAG] = "true";
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
process.env[FLAG] = undefined;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("env flag off → maybeBuffer always returns false", () => {
|
|
19
|
+
process.env[FLAG] = "false";
|
|
20
|
+
const flushes: string[][] = [];
|
|
21
|
+
const ib = createIngressBuffer<string>({
|
|
22
|
+
source: "test",
|
|
23
|
+
envFlag: FLAG,
|
|
24
|
+
timeoutMs: 20,
|
|
25
|
+
onFlush: (items) => {
|
|
26
|
+
flushes.push([...items]);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
expect(ib.enabled).toBe(false);
|
|
30
|
+
expect(ib.maybeBuffer("ctx", true, "a")).toBe(false);
|
|
31
|
+
expect(ib.isBuffered("ctx")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("no sibling in flight → maybeBuffer returns false", () => {
|
|
35
|
+
const ib = createIngressBuffer<string>({
|
|
36
|
+
source: "test",
|
|
37
|
+
envFlag: FLAG,
|
|
38
|
+
timeoutMs: 20,
|
|
39
|
+
onFlush: () => {},
|
|
40
|
+
});
|
|
41
|
+
expect(ib.enabled).toBe(true);
|
|
42
|
+
expect(ib.maybeBuffer("ctx", false, "a")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("empty contextKey → maybeBuffer returns false", () => {
|
|
46
|
+
const ib = createIngressBuffer<string>({
|
|
47
|
+
source: "test",
|
|
48
|
+
envFlag: FLAG,
|
|
49
|
+
timeoutMs: 20,
|
|
50
|
+
onFlush: () => {},
|
|
51
|
+
});
|
|
52
|
+
expect(ib.maybeBuffer("", true, "a")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("enabled + sibling in flight + contextKey → buffers", async () => {
|
|
56
|
+
const flushes: Array<{ items: string[]; key: string; reason: string }> = [];
|
|
57
|
+
const ib = createIngressBuffer<string>({
|
|
58
|
+
source: "test",
|
|
59
|
+
envFlag: FLAG,
|
|
60
|
+
timeoutMs: 20,
|
|
61
|
+
onFlush: (items, key, reason) => {
|
|
62
|
+
flushes.push({ items: [...items], key, reason });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
expect(ib.maybeBuffer("ctx", true, "one")).toBe(true);
|
|
66
|
+
expect(ib.maybeBuffer("ctx", true, "two")).toBe(true);
|
|
67
|
+
expect(ib.maybeBuffer("ctx", true, "three")).toBe(true);
|
|
68
|
+
expect(ib.count("ctx")).toBe(3);
|
|
69
|
+
|
|
70
|
+
await sleep(80);
|
|
71
|
+
expect(flushes.length).toBe(1);
|
|
72
|
+
expect(flushes[0]?.items).toEqual(["one", "two", "three"]);
|
|
73
|
+
expect(flushes[0]?.key).toBe("ctx");
|
|
74
|
+
expect(flushes[0]?.reason).toBe("timer");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("instantFlush resolves immediately with reason=manual", async () => {
|
|
78
|
+
const flushes: Array<{ items: string[]; reason: string }> = [];
|
|
79
|
+
const ib = createIngressBuffer<string>({
|
|
80
|
+
source: "test",
|
|
81
|
+
envFlag: FLAG,
|
|
82
|
+
timeoutMs: 5000, // long
|
|
83
|
+
onFlush: (items, _key, reason) => {
|
|
84
|
+
flushes.push({ items: [...items], reason });
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
ib.maybeBuffer("k", true, "x");
|
|
88
|
+
ib.maybeBuffer("k", true, "y");
|
|
89
|
+
await ib.instantFlush("k");
|
|
90
|
+
expect(flushes.length).toBe(1);
|
|
91
|
+
expect(flushes[0]?.items).toEqual(["x", "y"]);
|
|
92
|
+
expect(flushes[0]?.reason).toBe("manual");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("cancel drops items without flushing", async () => {
|
|
96
|
+
const flushes: string[][] = [];
|
|
97
|
+
const ib = createIngressBuffer<string>({
|
|
98
|
+
source: "test",
|
|
99
|
+
envFlag: FLAG,
|
|
100
|
+
timeoutMs: 20,
|
|
101
|
+
onFlush: (items) => {
|
|
102
|
+
flushes.push([...items]);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
ib.maybeBuffer("k", true, "a");
|
|
106
|
+
ib.cancel("k");
|
|
107
|
+
await sleep(50);
|
|
108
|
+
expect(flushes.length).toBe(0);
|
|
109
|
+
expect(ib.isBuffered("k")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
completeTask,
|
|
6
|
+
createAgent,
|
|
7
|
+
createTaskExtended,
|
|
8
|
+
getInProgressTasksByContextKey,
|
|
9
|
+
initDb,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
import { slackContextKey } from "../tasks/context-key";
|
|
12
|
+
|
|
13
|
+
const TEST_DB_PATH = "./test-context-key-db.sqlite";
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
initDb(TEST_DB_PATH);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
closeDb();
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(TEST_DB_PATH);
|
|
23
|
+
unlinkSync(`${TEST_DB_PATH}-wal`);
|
|
24
|
+
unlinkSync(`${TEST_DB_PATH}-shm`);
|
|
25
|
+
} catch {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("contextKey persistence + lookup", () => {
|
|
31
|
+
test("createTaskExtended persists contextKey and getInProgressTasksByContextKey returns it", () => {
|
|
32
|
+
const agent = createAgent({
|
|
33
|
+
name: "ctx-key-agent-1",
|
|
34
|
+
isLead: false,
|
|
35
|
+
status: "idle",
|
|
36
|
+
capabilities: [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const key = slackContextKey({ channelId: "C_TEST_1", threadTs: "1700000000.000001" });
|
|
40
|
+
const task = createTaskExtended("Hello", { agentId: agent.id, contextKey: key });
|
|
41
|
+
|
|
42
|
+
expect(task.contextKey).toBe(key);
|
|
43
|
+
|
|
44
|
+
const siblings = getInProgressTasksByContextKey(key);
|
|
45
|
+
expect(siblings.map((t) => t.id)).toContain(task.id);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("getInProgressTasksByContextKey excludes terminal tasks", () => {
|
|
49
|
+
const agent = createAgent({
|
|
50
|
+
name: "ctx-key-agent-2",
|
|
51
|
+
isLead: false,
|
|
52
|
+
status: "idle",
|
|
53
|
+
capabilities: [],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const key = slackContextKey({ channelId: "C_TEST_2", threadTs: "1700000000.000002" });
|
|
57
|
+
const done = createTaskExtended("Done task", { agentId: agent.id, contextKey: key });
|
|
58
|
+
const pending = createTaskExtended("Pending task", { agentId: agent.id, contextKey: key });
|
|
59
|
+
|
|
60
|
+
completeTask(done.id, "ok");
|
|
61
|
+
|
|
62
|
+
const siblings = getInProgressTasksByContextKey(key);
|
|
63
|
+
const ids = siblings.map((t) => t.id);
|
|
64
|
+
expect(ids).toContain(pending.id);
|
|
65
|
+
expect(ids).not.toContain(done.id);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("getInProgressTasksByContextKey returns empty for unknown key", () => {
|
|
69
|
+
const results = getInProgressTasksByContextKey("task:slack:C_NONE:0");
|
|
70
|
+
expect(results).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("child task inherits contextKey from parent", () => {
|
|
74
|
+
const agent = createAgent({
|
|
75
|
+
name: "ctx-key-agent-3",
|
|
76
|
+
isLead: false,
|
|
77
|
+
status: "idle",
|
|
78
|
+
capabilities: [],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const key = slackContextKey({ channelId: "C_TEST_3", threadTs: "1700000000.000003" });
|
|
82
|
+
const parent = createTaskExtended("Parent", { agentId: agent.id, contextKey: key });
|
|
83
|
+
const child = createTaskExtended("Child", { agentId: agent.id, parentTaskId: parent.id });
|
|
84
|
+
|
|
85
|
+
expect(child.contextKey).toBe(key);
|
|
86
|
+
});
|
|
87
|
+
});
|