@desplega.ai/agent-swarm 1.110.0 → 1.112.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 (119) hide show
  1. package/README.md +1 -1
  2. package/dist/{actions-837ktebk.js → actions-eckb4g8r.js} +6 -6
  3. package/dist/{app-85h97pg2.js → app-b05sgm02.js} +3 -3
  4. package/dist/{assistant-yhrta0c5.js → assistant-7255sbyw.js} +9 -9
  5. package/dist/{boot-reembed-5sd1t9r4.js → boot-reembed-y1bt3ttn.js} +4 -4
  6. package/dist/{boot-reembed-8xmfv2pq.js → boot-reembed-yv7awg4y.js} +3 -3
  7. package/dist/{boot-scrub-logs-fdw8wbtq.js → boot-scrub-logs-ddyaeqar.js} +2 -2
  8. package/dist/{claude-managed-setup-yhbcx0bz.js → claude-managed-setup-cm05wtek.js} +1 -1
  9. package/dist/{cli-8ps5wsk9.js → cli-0c3n0eba.js} +1 -1
  10. package/dist/{cli-kna0r84e.js → cli-15598a4k.js} +599 -51
  11. package/dist/{cli-8s0swp2t.js → cli-30d2ct3k.js} +3 -3
  12. package/dist/{cli-2h86bsfe.js → cli-3r5y6ayj.js} +2 -2
  13. package/dist/{cli-6ttdv68b.js → cli-52tbq17n.js} +1 -1
  14. package/dist/{cli-k6ssh5dk.js → cli-56s4ehzw.js} +1 -1
  15. package/dist/{cli-m3enfr8q.js → cli-6wscj9z0.js} +4 -4
  16. package/dist/{cli-5kav4x1a.js → cli-709t753c.js} +7 -2
  17. package/dist/{cli-g44ffxk5.js → cli-76d1vtvq.js} +11 -2
  18. package/dist/{cli-gb7dqnna.js → cli-7xkwgkrs.js} +1 -1
  19. package/dist/{cli-kg9v8h1w.js → cli-8f0dwca2.js} +1 -1
  20. package/dist/{cli-zw3cmjh6.js → cli-94vzr3nq.js} +4 -4
  21. package/dist/{cli-d8vmtyyr.js → cli-951w7c79.js} +1 -1
  22. package/dist/{cli-dhph6q98.js → cli-9p0qhead.js} +1 -1
  23. package/dist/{cli-vfyhrk9k.js → cli-a5e8n5hg.js} +1 -1
  24. package/dist/{cli-w6zp4qcx.js → cli-ceewqajw.js} +37 -39
  25. package/dist/{cli-r19ttke3.js → cli-h9jx5mag.js} +4 -4
  26. package/dist/{cli-4trfcacj.js → cli-hm27prrc.js} +5 -5
  27. package/dist/{cli-gtt1f14h.js → cli-pt7c7qxz.js} +3 -3
  28. package/dist/{cli-sjm0e1pa.js → cli-r1btc895.js} +2 -2
  29. package/dist/{cli-3baz70yp.js → cli-rp8pca98.js} +1 -1
  30. package/dist/{cli-dy01kwxk.js → cli-s9pj3c7z.js} +2 -2
  31. package/dist/{cli-0pzg1f8z.js → cli-tch0ypp6.js} +71 -24
  32. package/dist/{cli-3t97r3y1.js → cli-tg0f2wmz.js} +5 -5
  33. package/dist/{cli-amncg1dq.js → cli-tk9vyqzh.js} +66 -9
  34. package/dist/{cli-cwety9qq.js → cli-tp7e60s3.js} +4 -2
  35. package/dist/cli.js +11 -11
  36. package/dist/{codex-adapter-ebs12jef.js → codex-adapter-5cmy858w.js} +3 -3
  37. package/dist/{codex-login-4sywkz6v.js → codex-login-0h18cy3e.js} +1 -1
  38. package/dist/{codex-session-runner-3bx0b1hn.js → codex-session-runner-46q8mrba.js} +3 -3
  39. package/dist/{commands-3fh75nzv.js → commands-14g7taf4.js} +2 -2
  40. package/dist/{db-kwfxkhpw.js → db-3tge4jrn.js} +2 -2
  41. package/dist/{handlers-jj8rzetb.js → handlers-sy64egg6.js} +9 -9
  42. package/dist/{hook-1b45zzz1.js → hook-rnkzw8vp.js} +3 -3
  43. package/dist/{http-71964f7a.js → http-xx9xptcs.js} +234 -37
  44. package/dist/{index-e01wmtj0.js → index-3wr6v2mm.js} +7 -7
  45. package/dist/{index-464v93y6.js → index-brma88d4.js} +9 -9
  46. package/dist/{index-c35g752k.js → index-ha8e9xn2.js} +7 -11
  47. package/dist/{index-zm0x6vmf.js → index-zfx5p959.js} +8 -8
  48. package/dist/{keepalive-2gxqjgrx.js → keepalive-rbz05zad.js} +4 -4
  49. package/dist/{lead-twm4zp8y.js → lead-s790v7r0.js} +20 -20
  50. package/dist/{maintenance-1htsnyz6.js → maintenance-420rmadb.js} +4 -4
  51. package/dist/{onboard-1y1pdzt5.js → onboard-jgqamqnw.js} +2 -2
  52. package/dist/{otel-impl-pw9tnc80.js → otel-impl-ch3n3783.js} +1 -1
  53. package/dist/{pi-mono-adapter-ayr3akk8.js → pi-mono-adapter-kxjxpfgy.js} +2 -2
  54. package/dist/{pricing-refresh-tqvvrk7a.js → pricing-refresh-rmtrby6h.js} +4 -4
  55. package/dist/{seed-pricing-f23sehzq.js → seed-pricing-1szjzfj1.js} +3 -3
  56. package/dist/{setup-jtw9rz9t.js → setup-8jfzkyzv.js} +2 -2
  57. package/dist/{worker-jxrmzwb9.js → worker-40vq0pm8.js} +20 -20
  58. package/openapi.json +1010 -2
  59. package/package.json +1 -1
  60. package/src/be/memory/constants.ts +7 -0
  61. package/src/be/memory/graph-expansion.ts +167 -0
  62. package/src/be/memory/link-resolver.ts +81 -17
  63. package/src/be/memory/links-store.ts +208 -0
  64. package/src/be/memory/providers/sqlite-store.ts +2 -2
  65. package/src/be/memory/raters/retrieval.ts +2 -1
  66. package/src/be/memory/types.ts +1 -1
  67. package/src/be/memory/usefulness-stats.ts +259 -0
  68. package/src/be/migrations/107_memory_usefulness_indexes.sql +12 -0
  69. package/src/be/scripts/typecheck.ts +2 -0
  70. package/src/be/seed-scripts/catalog/smart-recall.ts +1 -5
  71. package/src/commands/runner.ts +154 -8
  72. package/src/github/handlers.ts +128 -25
  73. package/src/github/mentions-aliases.test.ts +4 -3
  74. package/src/http/codex-oauth-keep-warm.ts +107 -0
  75. package/src/http/index.ts +2 -0
  76. package/src/http/memory.ts +73 -6
  77. package/src/providers/codex-adapter.ts +66 -2
  78. package/src/providers/codex-oauth/auth-json.ts +18 -9
  79. package/src/providers/codex-oauth/flow.ts +1 -1
  80. package/src/providers/codex-oauth/storage.ts +88 -6
  81. package/src/providers/codex-oauth/types.ts +1 -1
  82. package/src/scripts-runtime/sdk-allowlist.ts +2 -0
  83. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  84. package/src/server.ts +4 -0
  85. package/src/slack/message-text.ts +25 -0
  86. package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +113 -11
  87. package/src/tests/codex-adapter.test.ts +27 -0
  88. package/src/tests/codex-oauth-keep-warm.test.ts +268 -0
  89. package/src/tests/codex-oauth-refresh-lock.test.ts +4 -4
  90. package/src/tests/codex-oauth-storage.test.ts +149 -6
  91. package/src/tests/codex-oauth.test.ts +12 -0
  92. package/src/tests/create-page-tool.test.ts +7 -0
  93. package/src/tests/entrypoint-codex-oauth-seed.test.ts +124 -0
  94. package/src/tests/github-event-filter.test.ts +3 -2
  95. package/src/tests/github-handlers-inline-comments.test.ts +114 -24
  96. package/src/tests/jira-webhook-lifecycle.test.ts +7 -0
  97. package/src/tests/memory-edit.test.ts +95 -0
  98. package/src/tests/memory-get-tool.test.ts +138 -0
  99. package/src/tests/memory-graph-expansion.test.ts +421 -0
  100. package/src/tests/memory-http-recall-gating.test.ts +13 -0
  101. package/src/tests/memory-link-resolver.test.ts +228 -2
  102. package/src/tests/memory-usefulness-endpoint.test.ts +392 -0
  103. package/src/tests/runner-repo-autostash.test.ts +168 -1
  104. package/src/tests/script-workflows-runtime-e2e.test.ts +1 -0
  105. package/src/tests/slack-delete.test.ts +157 -0
  106. package/src/tests/slack-message-text.test.ts +37 -1
  107. package/src/tests/slack-update.test.ts +167 -0
  108. package/src/tests/slack-upload-file.test.ts +135 -0
  109. package/src/tests/tool-annotations.test.ts +3 -0
  110. package/src/tools/inject-learning.ts +1 -0
  111. package/src/tools/memory-edit.ts +3 -2
  112. package/src/tools/memory-get.ts +66 -1
  113. package/src/tools/memory-search.ts +13 -3
  114. package/src/tools/slack-delete.ts +102 -0
  115. package/src/tools/slack-reply.ts +11 -3
  116. package/src/tools/slack-start-thread.ts +2 -0
  117. package/src/tools/slack-update.ts +127 -0
  118. package/src/tools/slack-upload-file.ts +11 -1
  119. package/src/tools/tool-config.ts +3 -1
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Windowed usefulness analytics over the memory measurement tables.
3
+ *
4
+ * Answers "is memory useful, per source and per retrieval arm" by reading the
5
+ * plumbing that Phases 1–2 of the memory-enhancements track already write:
6
+ * - `memory_retrieval` — one row per memory surfaced to a task
7
+ * (051 base + 096 contextKey/intent/eventType + 097 retrievalId/rank +
8
+ * 100 retrievalSource).
9
+ * - `memory_rating` — append-only RatingEvent audit (051 + 096
10
+ * contextKey); `source='implicit-citation'` rows are the citation signal.
11
+ * - `agent_memory.alpha/beta` — Beta-Binomial usefulness posteriors (051).
12
+ *
13
+ * Served by `GET /api/memory/usefulness` (src/http/memory.ts) as a sibling of
14
+ * the cheap `/api/memory/health` probe. Server-side only (owns SQL via
15
+ * `getDb()`); plan: thoughts/taras/plans/2026-07-02-memory-retrieval-v2-graph-and-measurement.md
16
+ * Phase 1.
17
+ */
18
+ import { getDb } from "@/be/db";
19
+
20
+ // ─── Shapes ──────────────────────────────────────────────────────────────────
21
+
22
+ export interface UsefulnessVolume {
23
+ /** memory_retrieval rows inside the window. */
24
+ retrievals: number;
25
+ /** Distinct memories surfaced inside the window. */
26
+ distinctMemories: number;
27
+ /** Distinct retrievalId groups (one per search/get call; pre-097 rows have NULL and are not counted). */
28
+ retrievalGroups: number;
29
+ /** Window rows split by eventType (CHECK: 'search' | 'get'). */
30
+ byEventType: { search: number; get: number };
31
+ }
32
+
33
+ export interface UsefulnessArmStats {
34
+ /** memory_retrieval.retrievalSource — 'vec' | 'fts' | 'hybrid' | 'fallback' ('graph' after DES-639a); NULL = pre-100 legacy rows. */
35
+ retrievalSource: string | null;
36
+ retrievals: number;
37
+ distinctMemories: number;
38
+ /** Window rows whose (taskId, memoryId) got a positive implicit-citation rating. */
39
+ citedRetrievals: number;
40
+ /** citedRetrievals / retrievals. */
41
+ citationRate: number;
42
+ }
43
+
44
+ export interface UsefulnessSourceCitation {
45
+ /** agent_memory.source — 'manual' | 'file_index' | 'session_summary' | 'task_completion'. */
46
+ source: string;
47
+ /** implicit-citation rating rows inside the window. */
48
+ ratings: number;
49
+ /** Ratings with signal > 0 (memory was cited in task evidence). */
50
+ positive: number;
51
+ /** positive / ratings — a true rate in [0, 1] (implicit-citation signals are ±1). */
52
+ citationRate: number;
53
+ /** AVG(signal) over the window's implicit-citation ratings — in [-1, 1]. */
54
+ avgSignal: number;
55
+ }
56
+
57
+ export interface UsefulnessPosteriorStats {
58
+ totalMemories: number;
59
+ /** Memories whose posterior moved off the Beta(1,1) prior. */
60
+ movedFromPrior: number;
61
+ /** AVG(alpha / (alpha + beta)) across all memories; null when the store is empty. */
62
+ avgPosteriorMean: number | null;
63
+ /** Same average restricted to moved memories; null when none moved. */
64
+ avgPosteriorMeanMoved: number | null;
65
+ /** Memories with posterior mean above `threshold`. */
66
+ aboveThreshold: number;
67
+ }
68
+
69
+ export interface UsefulnessSanity {
70
+ /** All-time memory_retrieval row count — "is anything flowing". */
71
+ totalRetrievalRows: number;
72
+ /** All-time memory_rating row count. */
73
+ totalRatingRows: number;
74
+ /** All-time memory_rating rows grouped by rater source. */
75
+ ratingsBySource: { source: string; count: number }[];
76
+ }
77
+
78
+ export interface UsefulnessStats {
79
+ windowDays: number;
80
+ threshold: number;
81
+ /** ISO cutoff — rows strictly newer than this are inside the window. */
82
+ cutoff: string;
83
+ volume: UsefulnessVolume;
84
+ byArm: UsefulnessArmStats[];
85
+ citationBySource: UsefulnessSourceCitation[];
86
+ posterior: UsefulnessPosteriorStats;
87
+ sanity: UsefulnessSanity;
88
+ }
89
+
90
+ export interface UsefulnessStatsOptions {
91
+ /** Window size in days (default 30). */
92
+ days?: number;
93
+ /** Posterior-mean threshold for the aboveThreshold count (default 0.6). */
94
+ threshold?: number;
95
+ }
96
+
97
+ // ─── Query ───────────────────────────────────────────────────────────────────
98
+
99
+ export function getUsefulnessStats(options: UsefulnessStatsOptions = {}): UsefulnessStats {
100
+ const days = options.days ?? 30;
101
+ const threshold = options.threshold ?? 0.6;
102
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
103
+ const db = getDb();
104
+
105
+ // Volume — window rows, distinct memories, per-call groups, eventType split.
106
+ // SUM(CASE …) instead of COUNT(*) FILTER for maximum SQLite compatibility.
107
+ const volumeRow = db
108
+ .prepare<
109
+ {
110
+ retrievals: number;
111
+ distinctMemories: number;
112
+ retrievalGroups: number;
113
+ searchEvents: number | null;
114
+ getEvents: number | null;
115
+ },
116
+ [string]
117
+ >(
118
+ `SELECT COUNT(*) AS retrievals,
119
+ COUNT(DISTINCT memoryId) AS distinctMemories,
120
+ COUNT(DISTINCT retrievalId) AS retrievalGroups,
121
+ SUM(CASE WHEN eventType = 'search' THEN 1 ELSE 0 END) AS searchEvents,
122
+ SUM(CASE WHEN eventType = 'get' THEN 1 ELSE 0 END) AS getEvents
123
+ FROM memory_retrieval
124
+ WHERE retrievedAt > ?`,
125
+ )
126
+ .get(cutoff)!;
127
+
128
+ // Per-arm breakdown — retrieval provenance plus "did this surfaced memory
129
+ // get cited in the same task". EXISTS keeps multi-rating (task, memory)
130
+ // pairs from inflating the count. Restricted to search events: memory-get
131
+ // rows carry no retrievalSource and would otherwise pollute the NULL
132
+ // ("legacy") arm. NULL eventType = pre-096 rows, kept (they were searches).
133
+ const armRows = db
134
+ .prepare<
135
+ {
136
+ retrievalSource: string | null;
137
+ retrievals: number;
138
+ distinctMemories: number;
139
+ citedRetrievals: number | null;
140
+ },
141
+ [string]
142
+ >(
143
+ `SELECT mr.retrievalSource AS retrievalSource,
144
+ COUNT(*) AS retrievals,
145
+ COUNT(DISTINCT mr.memoryId) AS distinctMemories,
146
+ SUM(CASE WHEN EXISTS (
147
+ SELECT 1
148
+ FROM memory_rating rt
149
+ WHERE rt.taskId = mr.taskId
150
+ AND rt.memoryId = mr.memoryId
151
+ AND rt.source = 'implicit-citation'
152
+ AND rt.signal > 0
153
+ ) THEN 1 ELSE 0 END) AS citedRetrievals
154
+ FROM memory_retrieval mr
155
+ WHERE mr.retrievedAt > ?
156
+ AND (mr.eventType IS NULL OR mr.eventType = 'search')
157
+ GROUP BY mr.retrievalSource
158
+ ORDER BY retrievals DESC`,
159
+ )
160
+ .all(cutoff);
161
+
162
+ // Citation rate per memory-source — the reconstructed R4 §A.2 query.
163
+ const sourceRows = db
164
+ .prepare<
165
+ {
166
+ source: string;
167
+ ratings: number;
168
+ positive: number | null;
169
+ avgSignal: number | null;
170
+ },
171
+ [string]
172
+ >(
173
+ `SELECT am.source AS source,
174
+ COUNT(*) AS ratings,
175
+ SUM(CASE WHEN mr.signal > 0 THEN 1 ELSE 0 END) AS positive,
176
+ AVG(mr.signal) AS avgSignal
177
+ FROM memory_rating mr
178
+ JOIN agent_memory am ON am.id = mr.memoryId
179
+ WHERE mr.source = 'implicit-citation'
180
+ AND mr.createdAt > ?
181
+ GROUP BY am.source
182
+ ORDER BY ratings DESC`,
183
+ )
184
+ .all(cutoff);
185
+
186
+ // Posterior movement — how far the Beta(1,1) priors have drifted.
187
+ const posteriorRow = db
188
+ .prepare<
189
+ {
190
+ totalMemories: number;
191
+ movedFromPrior: number | null;
192
+ avgPosteriorMean: number | null;
193
+ avgPosteriorMeanMoved: number | null;
194
+ aboveThreshold: number | null;
195
+ },
196
+ [number]
197
+ >(
198
+ `SELECT COUNT(*) AS totalMemories,
199
+ SUM(CASE WHEN alpha <> 1.0 OR beta <> 1.0 THEN 1 ELSE 0 END) AS movedFromPrior,
200
+ AVG(alpha / (alpha + beta)) AS avgPosteriorMean,
201
+ AVG(CASE WHEN alpha <> 1.0 OR beta <> 1.0
202
+ THEN alpha / (alpha + beta) END) AS avgPosteriorMeanMoved,
203
+ SUM(CASE WHEN alpha / (alpha + beta) > ? THEN 1 ELSE 0 END) AS aboveThreshold
204
+ FROM agent_memory`,
205
+ )
206
+ .get(threshold)!;
207
+
208
+ // Sanity — all-time totals, unwindowed: "is anything flowing at all".
209
+ const totalRetrievalRows = db
210
+ .prepare<{ n: number }, []>("SELECT COUNT(*) AS n FROM memory_retrieval")
211
+ .get()!.n;
212
+ const ratingsBySource = db
213
+ .prepare<{ source: string; count: number }, []>(
214
+ "SELECT source, COUNT(*) AS count FROM memory_rating GROUP BY source ORDER BY count DESC",
215
+ )
216
+ .all();
217
+ const totalRatingRows = ratingsBySource.reduce((sum, row) => sum + row.count, 0);
218
+
219
+ return {
220
+ windowDays: days,
221
+ threshold,
222
+ cutoff,
223
+ volume: {
224
+ retrievals: volumeRow.retrievals,
225
+ distinctMemories: volumeRow.distinctMemories,
226
+ retrievalGroups: volumeRow.retrievalGroups,
227
+ byEventType: {
228
+ search: volumeRow.searchEvents ?? 0,
229
+ get: volumeRow.getEvents ?? 0,
230
+ },
231
+ },
232
+ byArm: armRows.map((row) => ({
233
+ retrievalSource: row.retrievalSource,
234
+ retrievals: row.retrievals,
235
+ distinctMemories: row.distinctMemories,
236
+ citedRetrievals: row.citedRetrievals ?? 0,
237
+ citationRate: row.retrievals > 0 ? (row.citedRetrievals ?? 0) / row.retrievals : 0,
238
+ })),
239
+ citationBySource: sourceRows.map((row) => ({
240
+ source: row.source,
241
+ ratings: row.ratings,
242
+ positive: row.positive ?? 0,
243
+ citationRate: row.ratings > 0 ? (row.positive ?? 0) / row.ratings : 0,
244
+ avgSignal: row.avgSignal ?? 0,
245
+ })),
246
+ posterior: {
247
+ totalMemories: posteriorRow.totalMemories,
248
+ movedFromPrior: posteriorRow.movedFromPrior ?? 0,
249
+ avgPosteriorMean: posteriorRow.avgPosteriorMean,
250
+ avgPosteriorMeanMoved: posteriorRow.avgPosteriorMeanMoved,
251
+ aboveThreshold: posteriorRow.aboveThreshold ?? 0,
252
+ },
253
+ sanity: {
254
+ totalRetrievalRows,
255
+ totalRatingRows,
256
+ ratingsBySource,
257
+ },
258
+ };
259
+ }
@@ -0,0 +1,12 @@
1
+ -- Supporting indexes for the usefulness readout (GET /api/memory/usefulness)
2
+ -- and the per-arm citation EXISTS probe. Prior to this, the windowed volume
3
+ -- query scanned memory_retrieval (no retrievedAt index) and the citation
4
+ -- EXISTS probed memory_rating via the single-column taskId index only.
5
+
6
+ CREATE INDEX IF NOT EXISTS idx_memret_retrieved_at ON memory_retrieval(retrievedAt);
7
+
8
+ -- Per-arm citation probe: EXISTS (taskId, memoryId, source, signal).
9
+ CREATE INDEX IF NOT EXISTS idx_memrat_task_memory_source ON memory_rating(taskId, memoryId, source);
10
+
11
+ -- Citation-by-source window: WHERE source = ? AND createdAt > ?.
12
+ CREATE INDEX IF NOT EXISTS idx_memrat_source_created_at ON memory_rating(source, createdAt);
@@ -137,6 +137,8 @@ export interface SwarmSdk {
137
137
  slack_startThread(args: { channelId: string; message: string }): Promise<unknown>;
138
138
  slack_uploadFile(args: Record<string, unknown>): Promise<unknown>;
139
139
  slack_downloadFile(args: { url: string }): Promise<unknown>;
140
+ slack_delete(args: { channelId: string; messageTs: string }): Promise<unknown>;
141
+ slack_update(args: { channelId: string; messageTs: string; message: string }): Promise<unknown>;
140
142
 
141
143
  // --- write: messaging (internal) ---
142
144
  message_post(args: { channel?: string; content: string; to?: string }): Promise<unknown>;
@@ -15,17 +15,13 @@ export const argsSchema = z.object({
15
15
  .positive()
16
16
  .optional()
17
17
  .describe("Max results per query (default 10)"),
18
- dedupThreshold: z
19
- .number()
20
- .optional()
21
- .describe("Similarity threshold for dedup — memories above this are merged (default 0.92)"),
22
18
  });
23
19
 
24
20
  /** Multi-query fan-out memory recall with dedup and composite reranking. */
25
21
  export default async function smartRecall(args: any, ctx: any) {
26
22
  const parsed = argsSchema.safeParse(args);
27
23
  if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
28
- const { queries, scope = "all", limit = 10, dedupThreshold = 0.92 } = parsed.data;
24
+ const { queries, scope = "all", limit = 10 } = parsed.data;
29
25
 
30
26
  const allResults: any[] = [];
31
27
  for (const q of queries) {
@@ -196,9 +196,111 @@ async function listSwarmAutostashes(clonePath: string, role: string): Promise<Sw
196
196
  }
197
197
  }
198
198
 
199
+ /**
200
+ * Continuation task types that must NEVER take the destructive hard-reset path,
201
+ * even though they are not `taskType === "resume"`. Each of these ingress paths
202
+ * creates a NEW task that CONTINUES/cooperates with existing work — often on the
203
+ * same worker/reused clone — rather than starting genuinely fresh coding work:
204
+ * - "follow-up" / "reroute-decision": lead-owned continuations created by
205
+ * src/tasks/worker-follow-up.ts after a task completes/fails or needs
206
+ * re-delegation; they inherit the parent's vcsRepo/branch context via
207
+ * createTaskExtended's parentTaskId inheritance (src/be/db.ts).
208
+ * - "agentmail-reply": AgentMail follow-up on an EXISTING thread
209
+ * (src/agentmail/handlers.ts) — always carries `parentTaskId` pointing at
210
+ * the task it's continuing (as opposed to "agentmail-message", which fires
211
+ * only when no existing task was found for the thread).
212
+ * - "github-comment" / "github-review" / "gitlab-comment" / "gitlab-ci":
213
+ * tracker feedback on an ALREADY-OPEN PR/MR (review comments, review
214
+ * verdicts, CI failures) — the point is to keep working on the existing
215
+ * feature branch, not reset back to the default branch.
216
+ */
217
+ const CONTINUATION_TASK_TYPES = new Set([
218
+ "resume",
219
+ "follow-up",
220
+ "reroute-decision",
221
+ "agentmail-reply",
222
+ "github-comment",
223
+ "github-review",
224
+ "gitlab-comment",
225
+ "gitlab-ci",
226
+ ]);
227
+
228
+ /**
229
+ * Task statuses under which a task may have already mutated the shared clone
230
+ * (checked out a feature branch, left uncommitted/unpushed work) — or could
231
+ * still be doing so concurrently, e.g. with `maxTasks > 1` on the same worker.
232
+ * Mirrors the ACTIVE_TASK_STATUSES / getInProgressTasksByContextKey status
233
+ * sets already used server-side (src/agentmail/handlers.ts, src/be/db.ts) for
234
+ * the same "is this task still doing live work" question.
235
+ */
236
+ const ACTIVE_PARENT_STATUSES = new Set<string>(["pending", "in_progress", "offered", "paused"]);
237
+
238
+ /**
239
+ * A task is a "first kickoff" (safe to hard-reset the base branch) only when it
240
+ * is BOTH (a) not an explicit continuation task-type and (b) not parent-linked
241
+ * to a still-active task.
242
+ *
243
+ * `parentTaskId` alone is NOT a resume signal — send-task/trackers set it on
244
+ * ~every dispatched child task (verified 395/395 real coding tasks carried one;
245
+ * 0 were typed "resume"), so gating on its mere presence made the reset a no-op
246
+ * in production (see git history on this function). But the *opposite*
247
+ * over-correction — treating every non-"resume" task as a safe root kickoff —
248
+ * is also wrong: several ingress paths create parent-linked NON-resume tasks
249
+ * specifically to CONTINUE existing work, often on the same worker/reused
250
+ * clone:
251
+ * - Slack follow-ups (src/slack/handlers.ts) wire `parentTaskId:
252
+ * latestTask?.id` with NO distinguishing taskType at all.
253
+ * - Sibling-awareness (src/tasks/sibling-awareness.ts, applied to every
254
+ * ingress via createTaskWithSiblingAwareness) wires `parentTaskId` to an
255
+ * in-progress same-agent sibling for session continuity — again with no
256
+ * reserved taskType.
257
+ * - AgentMail replies, tracker comments/reviews/CI events — see
258
+ * CONTINUATION_TASK_TYPES.
259
+ * Since the first two carry no reserved taskType, a taskType denylist alone
260
+ * cannot catch them — we ALSO check whether `parentTaskId` currently points at
261
+ * a still-active task (ACTIVE_PARENT_STATUSES). If so, that parent/sibling may
262
+ * hold a checked-out feature branch or unpushed/uncommitted work in the SAME
263
+ * shared clone (especially with `maxTasks > 1`), so a hard reset there would
264
+ * destroy it — take the non-destructive merge path instead.
265
+ *
266
+ * `fetchParentTaskStatus` is injected so this stays a pure, unit-testable
267
+ * function; the real call site fetches `GET /api/tasks/{id}` (same pattern as
268
+ * fetchProviderSessionInfo below).
269
+ */
270
+ export async function isFirstKickoffTask(
271
+ task?: { taskType?: string; parentTaskId?: string } | null,
272
+ fetchParentTaskStatus?: (parentTaskId: string) => Promise<string | null | undefined>,
273
+ ): Promise<boolean> {
274
+ if (task?.taskType && CONTINUATION_TASK_TYPES.has(task.taskType)) return false;
275
+ if (task?.parentTaskId && fetchParentTaskStatus) {
276
+ const parentStatus = await fetchParentTaskStatus(task.parentTaskId);
277
+ if (parentStatus && ACTIVE_PARENT_STATUSES.has(parentStatus)) return false;
278
+ }
279
+ return true;
280
+ }
281
+
282
+ /** Fetch a task's current status by id. Returns null on any failure (not-found, network, etc). */
283
+ async function fetchTaskStatus(
284
+ apiUrl: string,
285
+ apiKey: string,
286
+ taskId: string,
287
+ ): Promise<string | null> {
288
+ const headers: Record<string, string> = {};
289
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
290
+ try {
291
+ const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, { headers });
292
+ if (!response.ok) return null;
293
+ const data = (await response.json()) as { status?: string };
294
+ return data.status ?? null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
199
300
  async function refreshExistingRepoForTask(
200
301
  repoConfig: { name: string; clonePath: string; defaultBranch: string },
201
302
  role: string,
303
+ isFirstKickoff: boolean,
202
304
  ): Promise<string | null> {
203
305
  const { name, clonePath, defaultBranch } = repoConfig;
204
306
  const statusResult =
@@ -219,13 +321,36 @@ async function refreshExistingRepoForTask(
219
321
  }
220
322
  }
221
323
 
324
+ const fetchSpec = `${defaultBranch}:refs/remotes/origin/${defaultBranch}`;
325
+ const remoteRef = `refs/remotes/origin/${defaultBranch}`;
326
+
222
327
  try {
223
- console.log(`[${role}] Refreshing ${name} from origin/${defaultBranch}...`);
224
- const fetchSpec = `${defaultBranch}:refs/remotes/origin/${defaultBranch}`;
225
- const remoteRef = `refs/remotes/origin/${defaultBranch}`;
226
328
  await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} fetch origin ${fetchSpec}`.quiet();
227
- await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} merge --no-edit --no-stat ${remoteRef}`.quiet();
228
- console.log(`[${role}] Refreshed ${name}`);
329
+
330
+ // First-kickoff guarantee (see call sites): the clone is REUSED task-to-task,
331
+ // so a leftover feature branch from a prior task can still be checked out here.
332
+ // On a genuine first kickoff (no parentTaskId / not a resume) we force the base
333
+ // branch back to a clean, current state before the caller creates a work branch
334
+ // off it — otherwise a diverged leftover branch would abort the merge below and
335
+ // leave the repo stale (only a warning was emitted). Resumes and follow-up/child
336
+ // tasks (which may legitimately still have that feature branch checked out with
337
+ // in-progress work) must NEVER take this path — merge is the safe default there.
338
+ if (isFirstKickoff) {
339
+ console.log(`[${role}] First kickoff for ${name} — resetting to ${remoteRef}...`);
340
+ try {
341
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} checkout ${defaultBranch}`.quiet();
342
+ } catch {
343
+ // Local branch may be missing (e.g. deleted by a prior task) — recreate it
344
+ // tracking the remote instead of failing the whole refresh.
345
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} checkout -B ${defaultBranch} ${remoteRef}`.quiet();
346
+ }
347
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} reset --hard ${remoteRef}`.quiet();
348
+ console.log(`[${role}] Reset ${name} to ${remoteRef}`);
349
+ } else {
350
+ console.log(`[${role}] Refreshing ${name} from ${remoteRef}...`);
351
+ await Bun.$`env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX git -C ${clonePath} merge --no-edit --no-stat ${remoteRef}`.quiet();
352
+ console.log(`[${role}] Refreshed ${name}`);
353
+ }
229
354
  return null;
230
355
  } catch (err) {
231
356
  const errorMsg = scrubSecrets((err as Error).message);
@@ -278,6 +403,13 @@ export async function ensureRepoForTask(
278
403
  hooks?: { enabled: boolean } | null;
279
404
  },
280
405
  role: string,
406
+ /**
407
+ * True only for a genuine first kickoff (a brand-new task, not a resume and
408
+ * not a follow-up/child task continuing prior work). Defaults to false —
409
+ * when in doubt, do NOT force-reset the base branch, since the clone is
410
+ * reused task-to-task and may hold another task's in-progress branch.
411
+ */
412
+ isFirstKickoff = false,
281
413
  ): Promise<{
282
414
  clonePath: string;
283
415
  claudeMd: string | null;
@@ -308,7 +440,11 @@ export async function ensureRepoForTask(
308
440
  console.log(`[${role}] Cloned ${name}`);
309
441
  } else {
310
442
  console.log(`[${role}] Repo ${name} already cloned at ${clonePath}`);
311
- warning = await refreshExistingRepoForTask({ name, clonePath, defaultBranch }, role);
443
+ warning = await refreshExistingRepoForTask(
444
+ { name, clonePath, defaultBranch },
445
+ role,
446
+ isFirstKickoff,
447
+ );
312
448
  }
313
449
 
314
450
  await installRepoHooksForTask(repoConfig, role);
@@ -4794,7 +4930,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4794
4930
  clonePath: `/workspace/personal/repos/${task.vcsRepo.split("/").pop() || task.vcsRepo}`,
4795
4931
  defaultBranch: "main",
4796
4932
  };
4797
- const repoContext = await ensureRepoForTask(effectiveConfig, role);
4933
+ // This is the paused-task-resume-after-restart path — never a first
4934
+ // kickoff, so never force-reset the base branch here.
4935
+ const repoContext = await ensureRepoForTask(effectiveConfig, role, false);
4798
4936
  if (repoContext?.clonePath) {
4799
4937
  resumeCwd = repoContext.clonePath;
4800
4938
  }
@@ -5187,7 +5325,15 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
5187
5325
  clonePath: `/workspace/personal/repos/${taskVcsRepo.split("/").pop() || taskVcsRepo}`,
5188
5326
  defaultBranch: "main",
5189
5327
  };
5190
- const repoResult = await ensureRepoForTask(effectiveConfig, role);
5328
+ // First kickoff = a genuinely new root task: not a continuation
5329
+ // task-type, and not parent-linked to a still-active task (Slack
5330
+ // follow-ups / sibling-awareness wire parentTaskId with no
5331
+ // reserved taskType). See isFirstKickoffTask() for the full
5332
+ // reasoning and CONTINUATION_TASK_TYPES for the denylist.
5333
+ const isFirstKickoff = await isFirstKickoffTask(taskObj, (parentTaskId) =>
5334
+ fetchTaskStatus(apiUrl, apiKey, parentTaskId),
5335
+ );
5336
+ const repoResult = await ensureRepoForTask(effectiveConfig, role, isFirstKickoff);
5191
5337
  currentRepoContext = {
5192
5338
  ...repoResult,
5193
5339
  guidelines: repoConfig?.guidelines ?? null,