chainlesschain 0.166.136 → 0.166.139

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.
@@ -1,3 +1,9 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import {
3
+ resolveTaskBinding,
4
+ taskBindingMatchCount,
5
+ advanceTaskBinding,
6
+ } from "./task-binding.js";
1
7
  import { randomUUID } from "node:crypto";
2
8
  import {
3
9
  canonicalDigest,
@@ -84,6 +90,7 @@ class CallbackSessionContextPort extends InMemorySessionContextPort {
84
90
  head,
85
91
  physicalAuthority,
86
92
  allowExternalization = false,
93
+ requiredUserMessages = [],
87
94
  }) {
88
95
  const initialHead =
89
96
  head ||
@@ -99,6 +106,7 @@ class CallbackSessionContextPort extends InMemorySessionContextPort {
99
106
  items: messagesToContextItems(messages, {
100
107
  sessionId,
101
108
  allowExternalization,
109
+ requiredUserMessages,
102
110
  }),
103
111
  },
104
112
  ]);
@@ -262,10 +270,22 @@ export async function compactLiveMessagesCanonical(
262
270
  !isCompactionHostBase(message) &&
263
271
  !isRuntimeOnlySessionMessage(message),
264
272
  );
273
+ const requiredUserBindings = (options.requiredUserBindings || []).map(
274
+ (binding) => ({
275
+ ...binding,
276
+ message: structuredClone(binding.message),
277
+ matchCount: taskBindingMatchCount(binding),
278
+ }),
279
+ );
280
+ const summarizerOptions = { ...(options.compressOptions || {}) };
281
+ delete summarizerOptions.requiredUserBindings;
282
+ for (const binding of requiredUserBindings)
283
+ resolveTaskBinding(liveMessages, binding);
265
284
  const ephemeralMessages = [];
285
+ const durableSourceIndexes = new Map();
266
286
  {
267
287
  let sequence = 0;
268
- for (const message of liveMessages) {
288
+ for (const [liveIndex, message] of liveMessages.entries()) {
269
289
  if (
270
290
  isRuntimeOnlySessionMessage(message) ||
271
291
  isCompactionHostBase(message) ||
@@ -274,9 +294,29 @@ export async function compactLiveMessagesCanonical(
274
294
  !getDurableSystemMessageProvenance(message))
275
295
  )
276
296
  ephemeralMessages.push({ sequence, message });
277
- else sequence += 1;
297
+ else {
298
+ durableSourceIndexes.set(liveIndex, sequence);
299
+ sequence += 1;
300
+ }
278
301
  }
279
302
  }
303
+ // Carry host-resolved source indexes into the verified authority projection.
304
+ // Identical historical user text is not a substitute for this source identity.
305
+ const requiredUserMessages = requiredUserBindings.map((binding) => ({
306
+ index: durableSourceIndexes.get(
307
+ resolveTaskBinding(liveMessages, binding).index,
308
+ ),
309
+ message: binding.message,
310
+ }));
311
+ const initialItems = messagesToContextItems(messages, {
312
+ sessionId,
313
+ allowExternalization: persistentSession,
314
+ requiredUserMessages,
315
+ });
316
+ const boundItems = requiredUserMessages.map(({ index }, bindingIndex) => ({
317
+ item: initialItems.find((item) => item.sourceRef.eventSequence === index),
318
+ binding: requiredUserBindings[bindingIndex],
319
+ }));
280
320
  if (typeof options.commit === "function") {
281
321
  const bootstrapRuntime = createCliContextMemoryRuntime({
282
322
  sessionId,
@@ -288,6 +328,7 @@ export async function compactLiveMessagesCanonical(
288
328
  if (persistentSession) {
289
329
  verifiedAuthoritySnapshot = await new JsonlSessionContextPort({
290
330
  sessionId,
331
+ requiredUserMessages,
291
332
  }).readSnapshot(sessionId);
292
333
  }
293
334
  const sessionPort = new CallbackSessionContextPort({
@@ -298,6 +339,7 @@ export async function compactLiveMessagesCanonical(
298
339
  trigger,
299
340
  summaryStats: () => summaryStats,
300
341
  allowExternalization: persistentSession,
342
+ requiredUserMessages,
301
343
  ...(verifiedAuthoritySnapshot?.head
302
344
  ? { head: verifiedAuthoritySnapshot.head, physicalAuthority: true }
303
345
  : {}),
@@ -312,6 +354,10 @@ export async function compactLiveMessagesCanonical(
312
354
  });
313
355
  } else if (persistentSession) {
314
356
  runtime = createCliContextMemoryRuntime({
357
+ sessionPort: new JsonlSessionContextPort({
358
+ sessionId,
359
+ requiredUserMessages,
360
+ }),
315
361
  sessionId,
316
362
  scopeKey: `cli:session:${sessionId}`,
317
363
  env: options.env,
@@ -336,6 +382,7 @@ export async function compactLiveMessagesCanonical(
336
382
  items: messagesToContextItems(messages, {
337
383
  sessionId,
338
384
  allowExternalization: false,
385
+ requiredUserMessages,
339
386
  }),
340
387
  },
341
388
  ]);
@@ -362,6 +409,7 @@ export async function compactLiveMessagesCanonical(
362
409
  const inputItems = messagesToContextItems(messages, {
363
410
  sessionId,
364
411
  allowExternalization: true,
412
+ requiredUserMessages,
365
413
  });
366
414
  const inputDigest = canonicalDigest(
367
415
  inputItems.map((item) => item.digest),
@@ -384,11 +432,59 @@ export async function compactLiveMessagesCanonical(
384
432
  const model = options.model || "default";
385
433
  const memoryRevision = await runtime.memoryPort.getRevision();
386
434
  let settledOutputItems = null;
387
- if (ephemeralMessages.length) {
435
+ {
388
436
  const appendCompaction = runtime.sessionPort.appendCompaction.bind(
389
437
  runtime.sessionPort,
390
438
  );
391
439
  runtime.sessionPort.appendCompaction = async (event, expectedHead) => {
440
+ const outputMessages = contextItemsToMessages(event.outputItems, {
441
+ sessionId,
442
+ });
443
+ for (const { item, binding } of boundItems) {
444
+ resolveTaskBinding(messagesInput, binding);
445
+ const retained = event.outputItems.filter(
446
+ (output) => output.itemId === item.itemId,
447
+ );
448
+ const originalOutputs = contextItemsToMessages(
449
+ event.outputItems.filter((output) =>
450
+ initialItems.some(
451
+ (input) =>
452
+ input.itemId === output.itemId &&
453
+ input.digest === output.digest,
454
+ ),
455
+ ),
456
+ { sessionId },
457
+ );
458
+ // A summary can faithfully retain an older identical user turn from
459
+ // its verified dropped parents. Such history is not a new task copy.
460
+ const derivedParents = new Set(
461
+ event.outputItems
462
+ .filter(
463
+ (output) =>
464
+ !initialItems.some((input) => input.itemId === output.itemId),
465
+ )
466
+ .flatMap((output) => output.provenance.parentDigests || []),
467
+ );
468
+ const historicalOutputs = contextItemsToMessages(
469
+ initialItems.filter((input) => derivedParents.has(input.digest)),
470
+ { sessionId },
471
+ );
472
+ const matches = (list) =>
473
+ list.filter((message) => isDeepStrictEqual(message, binding.message))
474
+ .length;
475
+ if (
476
+ retained.length !== 1 ||
477
+ retained[0].digest !== item.digest ||
478
+ matches(outputMessages) >
479
+ matches(originalOutputs) + matches(historicalOutputs)
480
+ ) {
481
+ const error = new Error(
482
+ "The current user task was lost, changed or duplicated in compaction output",
483
+ );
484
+ error.code = "CC_AGENT_TASK_CONTEXT_LOST";
485
+ throw error;
486
+ }
487
+ }
392
488
  const result = await appendCompaction(event, expectedHead);
393
489
  if (result?.ok === true) settledOutputItems = event.outputItems;
394
490
  return result;
@@ -427,7 +523,7 @@ export async function compactLiveMessagesCanonical(
427
523
  });
428
524
  const result = await compressor.compress(droppedMessages, {
429
525
  preserveToolPairs: true,
430
- ...(options.compressOptions || {}),
526
+ ...summarizerOptions,
431
527
  });
432
528
  summaryStats = result.stats || {};
433
529
  summaryReceipt = usageReceipt(summaryStats, provider, model);
@@ -513,7 +609,9 @@ export async function compactLiveMessagesCanonical(
513
609
  receipt,
514
610
  };
515
611
  }
516
- const snapshot = await runtime.sessionPort.readSnapshot(sessionId);
612
+ const snapshot = settledOutputItems
613
+ ? null
614
+ : await runtime.sessionPort.readSnapshot(sessionId);
517
615
  // Reattach host policy and private retry nudges at their durable sequence
518
616
  // boundaries. They remain live, never enter the receipt, and do not move a
519
617
  // retry instruction behind tool exchanges that happened after that retry.
@@ -531,11 +629,21 @@ export async function compactLiveMessagesCanonical(
531
629
  ephemeralMessages[ephemeralIndex].sequence <= sequence
532
630
  )
533
631
  restoredMessages.push(ephemeralMessages[ephemeralIndex++].message);
534
- restoredMessages.push(...contextItemsToMessages([item], { sessionId }));
632
+ const bound = boundItems.find((entry) => entry.item.itemId === item.itemId);
633
+ restoredMessages.push(
634
+ ...(bound
635
+ ? [bound.binding.original]
636
+ : contextItemsToMessages([item], { sessionId })),
637
+ );
535
638
  }
536
639
  restoredMessages.push(
537
640
  ...ephemeralMessages.slice(ephemeralIndex).map(({ message }) => message),
538
641
  );
642
+ // Advance only the host binding's known multiplicity after a verified commit;
643
+ // older identical turns may have been legitimately summarized away.
644
+ requiredUserBindings.forEach((_, index) => {
645
+ advanceTaskBinding(options.requiredUserBindings[index], restoredMessages);
646
+ });
539
647
  return {
540
648
  messages: restoredMessages,
541
649
  stats: {
@@ -206,17 +206,22 @@ export function messagesToContextItems(messagesInput, options = {}) {
206
206
  ? [...options.allowedSinks]
207
207
  : DEFAULT_ALLOWED_SINKS;
208
208
  const items = [];
209
- const requiredUser = options.requiredUserMessage;
210
- if (
211
- requiredUser != null &&
212
- (!Number.isSafeInteger(requiredUser.index) ||
213
- requiredUser.index < 0 ||
214
- requiredUser.index >= messages.length ||
215
- requiredUser.message?.role !== "user" ||
216
- !isDeepStrictEqual(messages[requiredUser.index], requiredUser.message))
217
- ) {
218
- throw new TypeError("Invalid current-user task binding");
209
+ const requiredUsers =
210
+ options.requiredUserMessages ||
211
+ (options.requiredUserMessage ? [options.requiredUserMessage] : []);
212
+ for (const requiredUser of requiredUsers) {
213
+ if (
214
+ requiredUser != null &&
215
+ (!Number.isSafeInteger(requiredUser.index) ||
216
+ requiredUser.index < 0 ||
217
+ requiredUser.index >= messages.length ||
218
+ requiredUser.message?.role !== "user" ||
219
+ !isDeepStrictEqual(messages[requiredUser.index], requiredUser.message))
220
+ ) {
221
+ throw new TypeError("Invalid current-user task binding");
222
+ }
219
223
  }
224
+ const requiredIndexes = new Set(requiredUsers.map(({ index }) => index));
220
225
  const trustedSystemIndexes = new Set(
221
226
  Array.isArray(options.trustedSystemIndexes)
222
227
  ? options.trustedSystemIndexes.filter(Number.isSafeInteger)
@@ -305,14 +310,13 @@ export function messagesToContextItems(messagesInput, options = {}) {
305
310
  const item = items[index];
306
311
  if (
307
312
  index !== lastIndex &&
308
- item.sourceRef.eventSequence !== requiredUser?.index
313
+ !requiredIndexes.has(item.sourceRef.eventSequence)
309
314
  )
310
315
  continue;
311
316
  const payload = JSON.parse(loadBundleContent(item, sessionId));
312
317
  if (
313
318
  payload.messages.at(-1)?.role === "user" &&
314
- (index === lastIndex ||
315
- item.sourceRef.eventSequence === requiredUser?.index)
319
+ (index === lastIndex || requiredIndexes.has(item.sourceRef.eventSequence))
316
320
  ) {
317
321
  const protectedItem = { ...item };
318
322
  delete protectedItem.digest;
@@ -0,0 +1,41 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ const committedMatchCounts = new WeakMap();
4
+ export function taskBindingMatchCount(binding) {
5
+ return committedMatchCounts.get(binding) ?? binding.matchCount ?? 1;
6
+ }
7
+ export function advanceTaskBinding(binding, messages) {
8
+ committedMatchCounts.set(
9
+ binding,
10
+ messages.filter((message) => isDeepStrictEqual(message, binding.message))
11
+ .length,
12
+ );
13
+ }
14
+
15
+ // A compaction may recreate objects, but may never reconstruct the user's task
16
+ // from a generated summary. Only an exact, unambiguous snapshot can relocate.
17
+ export function resolveTaskBinding(messages, binding) {
18
+ if (!binding) return null;
19
+ const identities = messages.flatMap((message, index) =>
20
+ message === binding.original ? [index] : [],
21
+ );
22
+ const matches = messages.flatMap((message, index) =>
23
+ message?.role === "user" && isDeepStrictEqual(message, binding.message)
24
+ ? [index]
25
+ : [],
26
+ );
27
+ if (
28
+ matches.length !== taskBindingMatchCount(binding) ||
29
+ identities.length > 1 ||
30
+ (identities.length === 1 && !matches.includes(identities[0])) ||
31
+ (!identities.length &&
32
+ (binding.allowContentRelocation !== true || matches.length !== 1))
33
+ ) {
34
+ const error = new Error(
35
+ "The current user task was lost, changed or duplicated in compaction context",
36
+ );
37
+ error.code = "CC_AGENT_TASK_CONTEXT_LOST";
38
+ throw error;
39
+ }
40
+ return { index: identities[0] ?? matches[0], message: binding.message };
41
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { renameWithRetry } from "../config-manager.js";
4
5
  import { redactSecretsInText } from "../ide-context-redaction.js";
5
6
  import {
6
7
  diagnosticExcerpt,
@@ -437,6 +438,11 @@ export class TaskWorklog {
437
438
  function writeProjection(cwd, sessionId, markdown) {
438
439
  const file = worklogPath(cwd, sessionId, true);
439
440
  const temporary = file + "." + randomUUID() + ".tmp";
441
+ let writeError;
442
+ const validatePath = () => {
443
+ if (worklogPath(cwd, sessionId) !== file)
444
+ throw new Error("Worklog workspace path changed while saving");
445
+ };
440
446
  try {
441
447
  const fd = fs.openSync(temporary, "wx", 0o600);
442
448
  try {
@@ -445,9 +451,26 @@ function writeProjection(cwd, sessionId, markdown) {
445
451
  } finally {
446
452
  fs.closeSync(fd);
447
453
  }
448
- worklogPath(cwd, sessionId);
449
- fs.renameSync(temporary, file);
454
+ // Windows readers can briefly lock the existing projection. Retry only
455
+ // the atomic replacement, rechecking directory/link boundaries each time.
456
+ renameWithRetry(temporary, file, {
457
+ attempts: process.platform === "win32" ? 8 : 1,
458
+ _rename: (source, target) => {
459
+ validatePath();
460
+ fs.renameSync(source, target);
461
+ },
462
+ });
463
+ } catch (error) {
464
+ writeError = error;
465
+ throw error;
450
466
  } finally {
451
- if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
467
+ try {
468
+ validatePath();
469
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
470
+ } catch (error) {
471
+ // Never follow a redirected directory to clean up, or hide the original
472
+ // write failure behind a second transient lock on its temporary file.
473
+ if (!writeError) throw error;
474
+ }
452
475
  }
453
476
  }