taskchef 7.14.1 → 7.15.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.
@@ -16,7 +16,7 @@ import {
16
16
  turnPresentation,
17
17
  } from "./state.js";
18
18
  import { openTaskFromControl } from "./actions.js";
19
- import { githubReferenceSegments } from "./github-links.js";
19
+ import { referenceSegments } from "./github-links.js";
20
20
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
21
21
 
22
22
  const state = {
@@ -61,28 +61,33 @@ const elements = {
61
61
  };
62
62
  let notificationDescriptionSerial = 0;
63
63
 
64
- function githubLink(link, { compact = false } = {}) {
64
+ function referenceLink(link, { compact = false } = {}) {
65
65
  const anchor = document.createElement("a");
66
66
  anchor.className = compact ? "github-link github-link-compact" : "github-link";
67
67
  anchor.href = link.url;
68
68
  anchor.target = "_blank";
69
69
  anchor.rel = "noopener noreferrer";
70
70
  anchor.textContent = link.label ?? link.text;
71
+ const githubKind = link.type === "issue"
72
+ ? ", GitHub issue"
73
+ : link.type === "pull"
74
+ ? ", GitHub pull request"
75
+ : link.provider === "github" || link.owner ? " on GitHub" : "";
71
76
  anchor.setAttribute(
72
77
  "aria-label",
73
- `${link.label ?? link.text} on GitHub (opens in a new tab)`,
78
+ `${link.label ?? link.text}${githubKind} (opens in a new tab)`,
74
79
  );
75
80
  anchor.addEventListener("click", (event) => event.stopPropagation());
76
81
  return anchor;
77
82
  }
78
83
 
79
- function appendGitHubText(container, text, task) {
80
- const children = githubReferenceSegments(text, {
84
+ function appendLinkedText(container, text, task) {
85
+ const children = referenceSegments(text, {
81
86
  projectRepositories: task.project?.githubRepos,
82
87
  taskRepository: task.relatedGitHubRepository,
83
88
  }).map((segment) => {
84
89
  if (segment.kind === "text") return document.createTextNode(segment.text);
85
- if (segment.kind === "link") return githubLink(segment);
90
+ if (segment.kind === "link") return referenceLink(segment);
86
91
  const ambiguous = document.createElement("span");
87
92
  ambiguous.className = "github-reference-ambiguous";
88
93
  ambiguous.textContent = segment.text;
@@ -99,7 +104,7 @@ function relatedGitHubLinks(task, { compact = false } = {}) {
99
104
  const container = document.createElement("nav");
100
105
  container.className = `github-links${compact ? " github-links-compact" : ""}`;
101
106
  container.setAttribute("aria-label", `Related GitHub links for ${task.title}`);
102
- const children = links.map((link) => githubLink(link, { compact }));
107
+ const children = links.map((link) => referenceLink(link, { compact }));
103
108
  if (task.relatedGitHubLinksTruncated) {
104
109
  const more = document.createElement("span");
105
110
  more.className = "github-links-more";
@@ -254,7 +259,7 @@ function notificationAnnouncement(notification) {
254
259
  notificationTitle(notification),
255
260
  notification.title,
256
261
  notification.summary,
257
- notification.turnId ? `Turn ${notification.turnId}` : null,
262
+ notification.turnRef ? `Turn ref ${notification.turnRef}` : null,
258
263
  formatRelativeTime(notification.timestamp),
259
264
  ].filter(Boolean).join(". ");
260
265
  }
@@ -297,7 +302,7 @@ function turnTimeline(task) {
297
302
  const turnStatus = presentation.status;
298
303
  status.className = `status status-${turnStatus}`;
299
304
  status.textContent = turnStatus.replaceAll("_", " ");
300
- const turnKey = turn.turnId ?? `no-turn:${index}`;
305
+ const turnKey = turn.turnRef ?? turn.turnId ?? `no-turn:${index}`;
301
306
  const timestamp = timestampControl(presentation.updatedAt, {
302
307
  accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
303
308
  key: `detail:${task.id}:turn:${turnKey}`,
@@ -307,7 +312,7 @@ function turnTimeline(task) {
307
312
  requestLabel.textContent = "Request";
308
313
  const request = document.createElement("p");
309
314
  request.className = "preserve-lines";
310
- appendGitHubText(
315
+ appendLinkedText(
311
316
  request,
312
317
  turn.requestSummary ?? "Request not recorded by this TaskChef version.",
313
318
  task,
@@ -316,12 +321,10 @@ function turnTimeline(task) {
316
321
  resultLabel.textContent = "Result";
317
322
  const result = document.createElement("p");
318
323
  result.className = "preserve-lines";
319
- appendGitHubText(result, presentation.summary, task);
324
+ appendLinkedText(result, presentation.summary, task);
320
325
  const turnMetadata = document.createElement("p");
321
326
  turnMetadata.className = "result-history-turn";
322
- turnMetadata.textContent = turn.turnId
323
- ? `Turn ${turn.turnId}`
324
- : "No turn ID (creation failure)";
327
+ turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
325
328
  item.append(header, requestLabel, request, resultLabel, result, turnMetadata);
326
329
  return item;
327
330
  });
@@ -352,9 +355,11 @@ function renderDialog(task) {
352
355
  elements.copyThreadId.disabled = !task.threadId;
353
356
  elements.dialogMetadata.replaceChildren(
354
357
  ...detailRow("Current status", taskStatusLabel(task)),
355
- ...detailRow("Current turn ID", task.turnId),
358
+ ...detailRow("Current turn ref", task.turnRef),
359
+ ...detailRow("Current Codex turn ID", task.turnId),
356
360
  ...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
357
- ...detailRow("Last result turn ID", task.lastResult?.turnId),
361
+ ...detailRow("Last result turn ref", task.lastResult?.turnRef),
362
+ ...detailRow("Last result Codex turn ID", task.lastResult?.turnId),
358
363
  ...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
359
364
  accessibleName: `Last result updated time for ${task.title}`,
360
365
  key: `detail:${task.id}:last-result-updated`,
@@ -424,12 +429,12 @@ function taskCard(task) {
424
429
  requestLabel.textContent = "Request";
425
430
  const request = document.createElement("span");
426
431
  request.className = "preserve-lines";
427
- request.textContent = latest.requestSummary;
432
+ appendLinkedText(request, latest.requestSummary, task);
428
433
  const resultLabel = document.createElement("strong");
429
434
  resultLabel.textContent = "Result";
430
435
  const result = document.createElement("span");
431
436
  result.className = "preserve-lines";
432
- result.textContent = latest.resultSummary;
437
+ appendLinkedText(result, latest.resultSummary, task);
433
438
  summary.replaceChildren(requestLabel, request, resultLabel, result);
434
439
  const relatedLinks = relatedGitHubLinks(task, { compact: true });
435
440
  const time = timestampControl(
@@ -1,5 +1,6 @@
1
1
  const GITHUB_REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+$/;
2
- const REFERENCE_PATTERN = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)\/(issues|pull)\/([1-9]\d*)(?![A-Za-z0-9_])|([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)#([1-9]\d*)(?![A-Za-z0-9_])|#([1-9]\d*)(?![A-Za-z0-9_])|https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?)\/?(?=$|[\s),.;:!?'"\]}>])/gi;
2
+ const REFERENCE_PATTERN = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)\/(issues|pull)\/([1-9]\d*)(?![A-Za-z0-9_])|([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)#([1-9]\d*)(?![A-Za-z0-9_])|#([1-9]\d*)(?![A-Za-z0-9_])|https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?)\/?(?=$|[\s#),.;:!?'"\]}>])/gi;
3
+ const ABSOLUTE_HTTP_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
3
4
  export const MAX_RELATED_GITHUB_LINKS = 20;
4
5
 
5
6
  function canonicalRepository(owner, repository) {
@@ -23,10 +24,35 @@ export function normalizeGitHubRepository(value) {
23
24
 
24
25
  function rawReferences(text) {
25
26
  const value = String(text ?? "");
27
+ const absoluteRanges = absoluteHttpCandidates(value);
26
28
  const references = [];
27
29
  for (const match of value.matchAll(REFERENCE_PATTERN)) {
28
30
  const start = match.index;
29
31
  const preceding = start > 0 ? value[start - 1] : "";
32
+ let explicitAbsoluteUrl = null;
33
+ const containingAbsoluteUrl = absoluteRanges.find((range) => (
34
+ start >= range.start && start < range.end
35
+ ));
36
+ if (containingAbsoluteUrl) {
37
+ const explicitGitHubUrl = Boolean(match[1] || match[9]);
38
+ let absoluteHost = null;
39
+ try {
40
+ absoluteHost = new URL(containingAbsoluteUrl.text).hostname.toLowerCase();
41
+ } catch {
42
+ // Invalid absolute URL tokens still block nested shorthand recognition.
43
+ }
44
+ const completeGitHubUrl = explicitGitHubUrl
45
+ && start === containingAbsoluteUrl.start
46
+ && (absoluteHost === "github.com" || absoluteHost === "www.github.com");
47
+ if (!completeGitHubUrl) continue;
48
+ const safeUrl = safeAbsoluteHttpUrl(containingAbsoluteUrl.text);
49
+ if (!safeUrl) continue;
50
+ explicitAbsoluteUrl = {
51
+ end: containingAbsoluteUrl.end,
52
+ suffix: containingAbsoluteUrl.text.slice(match[0].length),
53
+ text: containingAbsoluteUrl.text,
54
+ };
55
+ }
30
56
  if (match[8] && /[\w&#/]/.test(preceding)) continue;
31
57
  if ((match[5] || match[1] || match[9]) && /[\w/]/.test(preceding)) continue;
32
58
 
@@ -45,14 +71,15 @@ function rawReferences(text) {
45
71
  [owner, repository] = normalized.split("/");
46
72
  }
47
73
  references.push({
48
- end: start + match[0].length,
74
+ end: explicitAbsoluteUrl?.end ?? start + match[0].length,
49
75
  explicit,
50
76
  number,
51
77
  owner,
52
78
  repository,
53
79
  start,
54
- text: match[0],
80
+ text: explicitAbsoluteUrl?.text ?? match[0],
55
81
  type,
82
+ urlSuffix: explicitAbsoluteUrl?.suffix ?? "",
56
83
  });
57
84
  }
58
85
  return references;
@@ -83,26 +110,111 @@ function resolvedReference(reference, repositoryContext) {
83
110
  if (reference.type === "repository") {
84
111
  return {
85
112
  kind: "link",
113
+ label: `${owner}/${repositoryName}`,
86
114
  number: null,
87
115
  owner,
116
+ provider: "github",
88
117
  repository: repositoryName,
89
118
  text: reference.text,
90
119
  type: reference.type,
91
- url: `https://github.com/${owner}/${repositoryName}`,
120
+ url: `https://github.com/${owner}/${repositoryName}${reference.urlSuffix}`,
92
121
  };
93
122
  }
94
123
  const path = reference.type === "pull" ? "pull" : "issues";
95
124
  return {
96
125
  kind: "link",
126
+ label: `${owner}/${repositoryName}#${reference.number}`,
97
127
  number: reference.number,
98
128
  owner,
129
+ provider: "github",
99
130
  repository: repositoryName,
100
131
  text: reference.text,
101
132
  type: reference.type,
102
- url: `https://github.com/${owner}/${repositoryName}/${path}/${reference.number}`,
133
+ url: `https://github.com/${owner}/${repositoryName}/${path}/${reference.number}${reference.urlSuffix}`,
103
134
  };
104
135
  }
105
136
 
137
+ function trimAbsoluteUrl(candidate) {
138
+ let trimmed = candidate;
139
+ let previous;
140
+ do {
141
+ previous = trimmed;
142
+ trimmed = trimmed.replace(/[.,;:!?]+$/u, "");
143
+ for (const [opening, closing] of [["(", ")"], ["[", "]"], ["{", "}"]]) {
144
+ while (trimmed.endsWith(closing)) {
145
+ const openings = [...trimmed].filter((character) => character === opening).length;
146
+ const closings = [...trimmed].filter((character) => character === closing).length;
147
+ if (closings <= openings) break;
148
+ trimmed = trimmed.slice(0, -1);
149
+ }
150
+ }
151
+ } while (trimmed !== previous);
152
+ return trimmed;
153
+ }
154
+
155
+ function absoluteHttpCandidates(text) {
156
+ const candidates = [];
157
+ for (const match of String(text ?? "").matchAll(ABSOLUTE_HTTP_PATTERN)) {
158
+ const candidate = trimAbsoluteUrl(match[0]);
159
+ if (!candidate) continue;
160
+ candidates.push({
161
+ end: match.index + candidate.length,
162
+ start: match.index,
163
+ text: candidate,
164
+ });
165
+ }
166
+ return candidates;
167
+ }
168
+
169
+ function hasDotPathSegment(candidate) {
170
+ const authorityAndPath = candidate.slice(candidate.indexOf("//") + 2).split(/[?#]/u, 1)[0];
171
+ const pathStart = authorityAndPath.indexOf("/");
172
+ if (pathStart < 0) return false;
173
+ return authorityAndPath.slice(pathStart).split("/").some((segment) => {
174
+ try {
175
+ const decoded = decodeURIComponent(segment);
176
+ return decoded === "." || decoded === "..";
177
+ } catch {
178
+ return true;
179
+ }
180
+ });
181
+ }
182
+
183
+ function safeAbsoluteHttpUrl(candidate) {
184
+ try {
185
+ const url = new URL(candidate);
186
+ if (
187
+ !url.hostname
188
+ || url.username
189
+ || url.password
190
+ || candidate.includes("\\")
191
+ || hasDotPathSegment(candidate)
192
+ ) return null;
193
+ return url.href;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function rawAbsoluteHttpReferences(text) {
200
+ const references = [];
201
+ for (const candidate of absoluteHttpCandidates(text)) {
202
+ const url = safeAbsoluteHttpUrl(candidate.text);
203
+ if (!url) continue;
204
+ references.push({
205
+ end: candidate.end,
206
+ kind: "link",
207
+ label: candidate.text,
208
+ provider: "web",
209
+ start: candidate.start,
210
+ text: candidate.text,
211
+ type: "url",
212
+ url,
213
+ });
214
+ }
215
+ return references;
216
+ }
217
+
106
218
  export function githubReferenceSegments(text, {
107
219
  projectRepositories = [],
108
220
  taskRepository = null,
@@ -134,6 +246,45 @@ export function githubReferenceSegments(text, {
134
246
  return segments;
135
247
  }
136
248
 
249
+ export function referenceSegments(text, options = {}) {
250
+ const value = String(text ?? "");
251
+ const githubSegments = githubReferenceSegments(value, options);
252
+ const references = [];
253
+ let cursor = 0;
254
+ for (const segment of githubSegments) {
255
+ const start = value.indexOf(segment.text, cursor);
256
+ if (start < 0) continue;
257
+ if (segment.kind !== "text") {
258
+ references.push({ end: start + segment.text.length, segment, start });
259
+ }
260
+ cursor = start + segment.text.length;
261
+ }
262
+ const absoluteReferences = rawAbsoluteHttpReferences(value).filter((reference) => (
263
+ !references.some(({ end, start }) => reference.start < end && reference.end > start)
264
+ ));
265
+ if (absoluteReferences.length === 0) return githubSegments;
266
+
267
+ references.push(...absoluteReferences.map((segment) => ({
268
+ end: segment.end,
269
+ segment,
270
+ start: segment.start,
271
+ })));
272
+ references.sort((left, right) => left.start - right.start);
273
+
274
+ const segments = [];
275
+ let offset = 0;
276
+ for (const reference of references) {
277
+ if (reference.start < offset) continue;
278
+ if (reference.start > offset) {
279
+ segments.push({ kind: "text", text: value.slice(offset, reference.start) });
280
+ }
281
+ segments.push(reference.segment);
282
+ offset = reference.end;
283
+ }
284
+ if (offset < value.length) segments.push({ kind: "text", text: value.slice(offset) });
285
+ return segments;
286
+ }
287
+
137
288
  function taskTexts(task) {
138
289
  const texts = [task.instruction];
139
290
  for (const turn of task.turns ?? []) {
@@ -146,16 +297,9 @@ function taskTexts(task) {
146
297
  return texts.filter((text) => typeof text === "string" && text.length > 0);
147
298
  }
148
299
 
149
- function relatedLinkLabel(link, includeRepository, includeOwner) {
300
+ function relatedLinkLabel(link) {
150
301
  if (link.type === "repository") return `${link.owner}/${link.repository}`;
151
- const reference = link.type === "pull"
152
- ? `PR #${link.number}`
153
- : link.type === "issue"
154
- ? `Issue #${link.number}`
155
- : `#${link.number}`;
156
- if (!includeRepository) return reference;
157
- const repository = includeOwner ? `${link.owner}/${link.repository}` : link.repository;
158
- return `${repository} ${reference}`;
302
+ return `${link.owner}/${link.repository}#${link.number}`;
159
303
  }
160
304
 
161
305
  export function taskGitHubProjection(task) {
@@ -179,7 +323,7 @@ export function taskGitHubProjection(task) {
179
323
  taskRepository,
180
324
  })) {
181
325
  if (segment.kind !== "link") continue;
182
- const key = segment.url.toLowerCase();
326
+ const key = segment.url;
183
327
  if (seen.has(key)) continue;
184
328
  if (links.length === MAX_RELATED_GITHUB_LINKS) {
185
329
  truncated = true;
@@ -189,17 +333,9 @@ export function taskGitHubProjection(task) {
189
333
  links.push(segment);
190
334
  }
191
335
  }
192
- const repositories = new Set(links.map(({ owner, repository }) => `${owner}/${repository}`));
193
- const repositoryNames = new Map();
194
- for (const link of links) {
195
- const owners = repositoryNames.get(link.repository) ?? new Set();
196
- owners.add(link.owner);
197
- repositoryNames.set(link.repository, owners);
198
- }
199
- const includeRepository = repositories.size > 1;
200
336
  return {
201
337
  relatedGitHubLinks: links.map((link) => ({
202
- label: relatedLinkLabel(link, includeRepository, repositoryNames.get(link.repository).size > 1),
338
+ label: relatedLinkLabel(link),
203
339
  number: link.number,
204
340
  owner: link.owner,
205
341
  repository: link.repository,
@@ -44,6 +44,7 @@ export function latestTurnPresentation(task) {
44
44
  const requestSummary = turn?.requestSummary
45
45
  ?? (turn ? "Request not recorded by this TaskChef version." : task.title);
46
46
  return {
47
+ turnRef: turn?.turnRef ?? turn?.turnId ?? task.turnRef ?? task.turnId ?? null,
47
48
  turnId: turn?.turnId ?? task.turnId ?? null,
48
49
  startedAt: turn?.startedAt ?? task.updatedAt ?? task.createdAt ?? null,
49
50
  requestSummary,
@@ -70,11 +71,25 @@ export function mergeProjectedTurns(task, preservedTurns = []) {
70
71
  if (!task.latestTurn) return preservedTurns;
71
72
  const turns = [...preservedTurns];
72
73
  const lastIndex = turns.length - 1;
73
- if (lastIndex >= 0 && turns[lastIndex].turnId === task.latestTurn.turnId) {
74
+ const latestIdentity = task.latestTurn.turnRef ?? task.latestTurn.turnId;
75
+ const preservedIdentity = lastIndex >= 0
76
+ ? (turns[lastIndex].turnRef ?? turns[lastIndex].turnId)
77
+ : null;
78
+ const migratedFallbackIdentity = lastIndex >= 0
79
+ && preservedIdentity === null
80
+ && latestIdentity !== null
81
+ && turns[lastIndex].turnId == null
82
+ && task.latestTurn.turnId == null
83
+ && JSON.stringify({ ...turns[lastIndex], turnRef: null })
84
+ === JSON.stringify({ ...task.latestTurn, turnRef: null });
85
+ if (
86
+ lastIndex >= 0
87
+ && (preservedIdentity === latestIdentity || migratedFallbackIdentity)
88
+ ) {
74
89
  turns[lastIndex] = task.latestTurn;
75
90
  } else {
76
91
  if (
77
- task.schemaVersion === 8
92
+ task.schemaVersion >= 8
78
93
  && lastIndex >= 0
79
94
  && turns[lastIndex].result === null
80
95
  ) {
@@ -144,7 +159,30 @@ export function nextDateFilterRefreshDelay(tasks, filter, now = Date.now()) {
144
159
  }
145
160
 
146
161
  export function taskSignature(task) {
147
- return JSON.stringify([task.id, task.turnId ?? null, task.status ?? "unresolved"]);
162
+ return JSON.stringify([
163
+ task.id,
164
+ task.turnRef ?? task.turnId ?? null,
165
+ task.turnId ?? null,
166
+ task.status ?? "unresolved",
167
+ ]);
168
+ }
169
+
170
+ function signaturesDifferOnlyByMigratedFallback(previous, next) {
171
+ try {
172
+ const before = JSON.parse(previous);
173
+ const after = JSON.parse(next);
174
+ return Array.isArray(before)
175
+ && Array.isArray(after)
176
+ && before.length === after.length
177
+ && before[1] === null
178
+ && after[1] !== null
179
+ && before[2] === null
180
+ && after[2] === null
181
+ && before[3] !== "working"
182
+ && before.every((value, index) => index === 1 || value === after[index]);
183
+ } catch {
184
+ return false;
185
+ }
148
186
  }
149
187
 
150
188
  export function findCurrentTask(tasks, taskId) {
@@ -162,14 +200,15 @@ function notificationIdentity(task, event) {
162
200
  if (event === "created") {
163
201
  return JSON.stringify([task.id, null, event, task.createdAt ?? null]);
164
202
  }
165
- return JSON.stringify([task.id, task.turnId ?? null, event]);
203
+ return JSON.stringify([task.id, task.turnRef ?? task.turnId ?? null, event]);
166
204
  }
167
205
 
168
206
  function eventTimestamp(task, event) {
169
207
  if (event === "created") return task.createdAt ?? task.updatedAt ?? null;
170
208
  if (
171
209
  task.lastResult?.status === task.status
172
- && task.lastResult?.turnId === task.turnId
210
+ && (task.lastResult?.turnRef ?? task.lastResult?.turnId)
211
+ === (task.turnRef ?? task.turnId)
173
212
  ) {
174
213
  return task.lastResult.updatedAt;
175
214
  }
@@ -180,7 +219,8 @@ function eventSummary(task, event) {
180
219
  if (!["completed", "needs_input", "failed"].includes(event)) return null;
181
220
  if (
182
221
  task.lastResult?.status === task.status
183
- && task.lastResult?.turnId === task.turnId
222
+ && (task.lastResult?.turnRef ?? task.lastResult?.turnId)
223
+ === (task.turnRef ?? task.turnId)
184
224
  ) {
185
225
  return task.lastResult.summary;
186
226
  }
@@ -195,6 +235,7 @@ export function notificationSnapshot(task, event = lifecycleEvent(task)) {
195
235
  title: task.title,
196
236
  status: created ? "working" : task.status,
197
237
  event,
238
+ turnRef: created ? null : task.turnRef ?? task.turnId ?? null,
198
239
  turnId: created ? null : task.turnId ?? null,
199
240
  timestamp: eventTimestamp(task, event),
200
241
  summary: eventSummary(task, event),
@@ -205,11 +246,16 @@ function resultNotificationSnapshot(task) {
205
246
  const result = task.lastResult;
206
247
  if (!result) return null;
207
248
  return Object.freeze({
208
- id: notificationIdentity({ ...task, turnId: result.turnId }, result.status),
249
+ id: notificationIdentity({
250
+ ...task,
251
+ turnRef: result.turnRef ?? result.turnId,
252
+ turnId: result.turnId,
253
+ }, result.status),
209
254
  taskId: task.id,
210
255
  title: task.title,
211
256
  status: result.status,
212
257
  event: result.status,
258
+ turnRef: result.turnRef ?? result.turnId ?? null,
213
259
  turnId: result.turnId ?? null,
214
260
  timestamp: result.updatedAt,
215
261
  summary: result.summary,
@@ -259,12 +305,18 @@ export function reconcileNotifications(
259
305
  const candidates = [];
260
306
  if (!signatures.has(task.id)) {
261
307
  const resultNotification = resultNotificationSnapshot(task);
262
- if (task.turnId || task.status !== "working" || resultNotification) {
308
+ if (task.turnRef || task.turnId || task.status !== "working" || resultNotification) {
263
309
  candidates.push(notificationSnapshot(task));
264
310
  if (resultNotification) candidates.push(resultNotification);
265
311
  }
266
312
  candidates.push(notificationSnapshot(task, "created"));
267
- } else if (signatures.get(task.id) !== nextSignatures.get(task.id)) {
313
+ } else if (
314
+ signatures.get(task.id) !== nextSignatures.get(task.id)
315
+ && !signaturesDifferOnlyByMigratedFallback(
316
+ signatures.get(task.id),
317
+ nextSignatures.get(task.id),
318
+ )
319
+ ) {
268
320
  candidates.push(notificationSnapshot(task));
269
321
  const resultNotification = resultNotificationSnapshot(task);
270
322
  if (resultNotification) candidates.push(resultNotification);
package/src/dashboard.js CHANGED
@@ -138,10 +138,13 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
138
138
  boundedText(task.instruction, 250_000, `${name} instruction`);
139
139
  boundedText(task.summary, 2_000, `${name} summary`);
140
140
  boundedText(task.threadId, 512, `${name} thread ID`);
141
+ boundedText(task.turnRef, 512, `${name} turn ref`);
141
142
  boundedText(task.turnId, 512, `${name} turn ID`);
142
143
  boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
144
+ boundedText(task.lastResult?.turnRef, 512, `${name} last result turn ref`);
143
145
  boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
144
146
  boundedText(task.latestTurn?.requestSummary, 1_000, `${name} latest request summary`);
147
+ boundedText(task.latestTurn?.turnRef, 512, `${name} latest turn ref`);
145
148
  boundedText(task.latestTurn?.turnId, 512, `${name} latest turn ID`);
146
149
  const turns = task.turns ?? [];
147
150
  if (turns.length > 10_000) {
@@ -149,6 +152,7 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
149
152
  }
150
153
  for (const [turnIndex, turn] of turns.entries()) {
151
154
  boundedText(turn.requestSummary, 1_000, `${name} turn ${turnIndex + 1} request summary`);
155
+ boundedText(turn.turnRef, 512, `${name} turn ${turnIndex + 1} turn ref`);
152
156
  boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
153
157
  boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
154
158
  }
@@ -158,6 +162,7 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
158
162
  }
159
163
  for (const [resultIndex, result] of results.entries()) {
160
164
  boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
165
+ boundedText(result.turnRef, 512, `${name} result ${resultIndex + 1} turn ref`);
161
166
  boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
162
167
  }
163
168
  boundedText(task.project.name, 1_000, `${name} project name`);
package/src/delegation.js CHANGED
@@ -70,17 +70,19 @@ function toolIdentifier(value) {
70
70
  return normalized.length > 0 ? normalized : null;
71
71
  }
72
72
 
73
- function attachCreationRecovery(error, taskId, resultReporting) {
73
+ function attachCreationRecovery(error, taskId, turnRef, resultReporting) {
74
74
  const creationError = error instanceof Error ? error : new Error(String(error));
75
75
  try {
76
76
  Object.defineProperties(creationError, {
77
77
  taskChefTaskId: { value: taskId, enumerable: true },
78
+ taskChefTurnRef: { value: turnRef, enumerable: true },
78
79
  taskChefResultReporting: { value: resultReporting, enumerable: true },
79
80
  });
80
81
  return creationError;
81
82
  } catch {
82
83
  const wrapped = new Error(`Executor creation failed for recorded TaskChef task ${taskId}.`, { cause: creationError });
83
84
  wrapped.taskChefTaskId = taskId;
85
+ wrapped.taskChefTurnRef = turnRef;
84
86
  wrapped.taskChefResultReporting = resultReporting;
85
87
  return wrapped;
86
88
  }
@@ -238,16 +240,29 @@ export async function createAndRecordDelegation(input) {
238
240
  try {
239
241
  createResult = parseToolResult(await createThread({ prompt: prepared.instruction, title, target }), "create_thread result");
240
242
  } catch (error) {
243
+ const creationFailureTurnRef = randomUUID();
241
244
  let resultReporting = "unavailable";
242
245
  if (reportRecordedResult !== null) {
243
246
  try {
244
- await reportRecordedResult({ taskId: prepared.id, threadId: null, turnId: null, status: "failed", summary: "Executor creation failed before the executor started." });
247
+ await reportRecordedResult({
248
+ taskId: prepared.id,
249
+ threadId: null,
250
+ turnRef: creationFailureTurnRef,
251
+ turnId: null,
252
+ status: "failed",
253
+ summary: "Executor creation failed before the executor started.",
254
+ });
245
255
  resultReporting = "recorded";
246
256
  } catch {
247
257
  resultReporting = "failed";
248
258
  }
249
259
  }
250
- throw attachCreationRecovery(error, prepared.id, resultReporting);
260
+ throw attachCreationRecovery(
261
+ error,
262
+ prepared.id,
263
+ creationFailureTurnRef,
264
+ resultReporting,
265
+ );
251
266
  }
252
267
 
253
268
  const returnedThreadId = toolIdentifier(createResult.threadId);