@gajae-code/coding-agent 0.12.10 → 0.12.11

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.
@@ -899,6 +899,7 @@ export interface ResumeSessionIdentity {
899
899
  size: number;
900
900
  mtimeMs: number;
901
901
  mtimeNs: bigint;
902
+ ctimeNs?: bigint;
902
903
  sha256: string;
903
904
  }
904
905
 
@@ -918,6 +919,21 @@ export interface ResumeTailError {
918
919
  }
919
920
 
920
921
  export type SessionDirectoryMigrationPolicy = ManagedMigrationPolicy;
922
+ export type SessionAppendPersistenceFailurePhase = "current_append" | "prior_failure";
923
+
924
+ export class SessionAppendPersistenceError extends Error {
925
+ readonly phase: SessionAppendPersistenceFailurePhase;
926
+ readonly entryId: string;
927
+ readonly persistenceError: Error;
928
+
929
+ constructor(phase: SessionAppendPersistenceFailurePhase, entryId: string, persistenceError: Error) {
930
+ super(persistenceError.message, { cause: persistenceError });
931
+ this.name = "SessionAppendPersistenceError";
932
+ this.phase = phase;
933
+ this.entryId = entryId;
934
+ this.persistenceError = persistenceError;
935
+ }
936
+ }
921
937
 
922
938
  export class SessionManagedStorageError extends Error {
923
939
  readonly code = "managed_storage_unsupported";
@@ -2161,6 +2177,7 @@ function sameResumeIdentity(left: ResumeSessionIdentity, right: ResumeSessionIde
2161
2177
  left.size === right.size &&
2162
2178
  left.mtimeMs === right.mtimeMs &&
2163
2179
  left.mtimeNs === right.mtimeNs &&
2180
+ (left.ctimeNs === undefined || right.ctimeNs === undefined || left.ctimeNs === right.ctimeNs) &&
2164
2181
  left.sha256 === right.sha256
2165
2182
  );
2166
2183
  }
@@ -2174,7 +2191,8 @@ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): bo
2174
2191
  left.nlink === right.nlink &&
2175
2192
  left.size === right.size &&
2176
2193
  left.mtimeMs === right.mtimeMs &&
2177
- left.mtimeNs === right.mtimeNs
2194
+ left.mtimeNs === right.mtimeNs &&
2195
+ left.ctimeNs === right.ctimeNs
2178
2196
  );
2179
2197
  }
2180
2198
 
@@ -2372,6 +2390,7 @@ function inspectResumeSessionFile(
2372
2390
  size: snapshot.size,
2373
2391
  mtimeMs: snapshot.mtimeMs,
2374
2392
  mtimeNs: snapshot.mtimeNs,
2393
+ ctimeNs: snapshot.ctimeNs,
2375
2394
  sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
2376
2395
  };
2377
2396
  return { identity, content: bytes, entries, context, migrationApplied };
@@ -2380,6 +2399,46 @@ function inspectResumeSessionFile(
2380
2399
  }
2381
2400
  }
2382
2401
 
2402
+ function fsyncResumeSessionIdentity(expected: ResumeSessionIdentity): void {
2403
+ const fd = fs.openSync(expected.canonicalPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
2404
+ try {
2405
+ const before = fs.fstatSync(fd, { bigint: true });
2406
+ if (
2407
+ before.dev !== expected.dev ||
2408
+ before.ino !== expected.ino ||
2409
+ (expected.nlink !== undefined && before.nlink !== expected.nlink) ||
2410
+ Number(before.size) !== expected.size ||
2411
+ before.mtimeNs !== expected.mtimeNs ||
2412
+ (expected.ctimeNs !== undefined && before.ctimeNs !== expected.ctimeNs)
2413
+ ) {
2414
+ throw new Error("session_persistence_recovery_identity_mismatch");
2415
+ }
2416
+ fs.fsyncSync(fd);
2417
+ const after = fs.fstatSync(fd, { bigint: true });
2418
+ const named = fs.lstatSync(expected.canonicalPath, { bigint: true });
2419
+ if (
2420
+ after.dev !== before.dev ||
2421
+ after.ino !== before.ino ||
2422
+ after.nlink !== before.nlink ||
2423
+ after.size !== before.size ||
2424
+ after.mtimeNs !== before.mtimeNs ||
2425
+ after.ctimeNs !== before.ctimeNs ||
2426
+ named.dev !== after.dev ||
2427
+ named.ino !== after.ino ||
2428
+ named.nlink !== after.nlink
2429
+ ) {
2430
+ throw new Error("session_persistence_recovery_identity_mismatch");
2431
+ }
2432
+ } finally {
2433
+ fs.closeSync(fd);
2434
+ }
2435
+ const parentFd = fs.openSync(path.dirname(expected.canonicalPath), fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
2436
+ try {
2437
+ fs.fsyncSync(parentFd);
2438
+ } finally {
2439
+ fs.closeSync(parentFd);
2440
+ }
2441
+ }
2383
2442
  /** Revalidate previously parsed authority without another decode, parse, migration, or context build. */
2384
2443
  function revalidateResumeSessionIdentity(
2385
2444
  filePath: string,
@@ -2410,6 +2469,7 @@ function revalidateResumeSessionIdentity(
2410
2469
  size: snapshot.stat.size,
2411
2470
  mtimeMs: snapshot.stat.mtimeMs,
2412
2471
  mtimeNs: snapshot.stat.mtimeNs,
2472
+ ctimeNs: snapshot.stat.ctimeNs,
2413
2473
  sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
2414
2474
  };
2415
2475
  return sameResumeIdentity(expected, observed) ? { kind: "valid" } : { kind: "error", reason: "identity-mismatch" };
@@ -4751,7 +4811,9 @@ export class SessionManager {
4751
4811
  #recoveryPromotionTranscriptPath: string | undefined;
4752
4812
  #memoryGuardParticipantIngressToken: symbol | undefined;
4753
4813
  #fileEntries: FileEntry[] = [];
4754
- #pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
4814
+ #pendingStrictAdoption:
4815
+ | { canonicalPath: string; identity: ResumeSessionIdentity; inspection?: ResumeInspectionSnapshot }
4816
+ | undefined;
4755
4817
  #byId: Map<string, SessionEntry> = new Map();
4756
4818
  #labelsById: Map<string, string> = new Map();
4757
4819
  #leafId: string | null = null;
@@ -5501,11 +5563,14 @@ export class SessionManager {
5501
5563
  if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
5502
5564
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5503
5565
  if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
5504
- throw new Error("Prepared managed session changed before strict adoption.");
5566
+ throw new Error("Prepared session changed before strict adoption.");
5505
5567
  }
5506
5568
  let entries: FileEntry[];
5507
5569
  let candidateMigrationApplied = false;
5508
- if (this.storage.existsSync(resolvedSessionFile)) {
5570
+ if (strictAdoption?.inspection && resolvedSessionFile === strictAdoption.canonicalPath) {
5571
+ entries = strictAdoption.inspection.entries;
5572
+ candidateMigrationApplied = strictAdoption.inspection.migrationApplied;
5573
+ } else if (this.storage.existsSync(resolvedSessionFile)) {
5509
5574
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5510
5575
  if ("kind" in inspected) throw new Error(`Could not switch session: ${inspected.reason}`);
5511
5576
  entries = inspected.entries;
@@ -5516,7 +5581,7 @@ export class SessionManager {
5516
5581
  if (strictAdoption) {
5517
5582
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5518
5583
  if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
5519
- throw new Error("Prepared managed session changed during strict adoption.");
5584
+ throw new Error("Prepared session changed during strict adoption.");
5520
5585
  }
5521
5586
  if (entries.length > 0) {
5522
5587
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
@@ -5541,6 +5606,13 @@ export class SessionManager {
5541
5606
  prepared.dispose();
5542
5607
  throw error;
5543
5608
  }
5609
+ if (strictAdoption) {
5610
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5611
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity)) {
5612
+ prepared.dispose();
5613
+ throw new Error("Prepared session changed before strict adoption commit.");
5614
+ }
5615
+ }
5544
5616
  const previous = {
5545
5617
  sessionId: this.#sessionId,
5546
5618
  sessionName: this.#sessionName,
@@ -8265,13 +8337,31 @@ export class SessionManager {
8265
8337
  this.#assertRecoveryHydrationWritable();
8266
8338
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
8267
8339
  const residentEntry = this.#prepareEntryForCurrentResidentStore(normalizedEntry) as SessionEntry;
8340
+ const previousLeafId = this.#leafId;
8341
+ const priorPersistenceError = this.#persistError;
8268
8342
  this.#fileEntries.push(residentEntry);
8269
8343
  this.#byId.set(residentEntry.id, residentEntry);
8270
8344
  this.#leafId = residentEntry.id;
8271
8345
  this.#bumpEntryRevision();
8272
8346
  this.#leafRevision++;
8273
8347
  if (entry.type === "label") this.#labelRevision++;
8274
- this._persist(residentEntry);
8348
+ try {
8349
+ this._persist(residentEntry);
8350
+ } catch (error) {
8351
+ const removed = this.#fileEntries.pop();
8352
+ if (removed !== residentEntry)
8353
+ throw new Error("Session append rollback lost resident ordering.", { cause: error });
8354
+ this.#byId.delete(residentEntry.id);
8355
+ this.#leafId = previousLeafId;
8356
+ this.#bumpEntryRevision();
8357
+ this.#leafRevision++;
8358
+ if (entry.type === "label") this.#labelRevision++;
8359
+ throw new SessionAppendPersistenceError(
8360
+ priorPersistenceError ? "prior_failure" : "current_append",
8361
+ residentEntry.id,
8362
+ this.#persistError ?? toError(error),
8363
+ );
8364
+ }
8275
8365
  // Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
8276
8366
  // malformed task-result or assistant usage shape reaching the append path must not
8277
8367
  // crash or poison getUsageStatistics() either.
@@ -8513,6 +8603,50 @@ export class SessionManager {
8513
8603
  this.#replayMetadataRevision++;
8514
8604
  }
8515
8605
 
8606
+ /**
8607
+ * Rehydrate the canonical transcript after a synchronous persistence failure.
8608
+ *
8609
+ * The failed append may have committed before reporting an uncertain outcome,
8610
+ * so callers must not clear the sticky error or retry against the resident
8611
+ * branch. Reloading the exact session file is the only supported recovery
8612
+ * boundary for both managed and explicit persistent destinations.
8613
+ */
8614
+ async recoverPersistenceFailure(): Promise<void> {
8615
+ const persistenceError = this.#persistError;
8616
+ if (!persistenceError) return;
8617
+ if (!this.#sessionFile) throw persistenceError;
8618
+ const sessionFile = this.#sessionFile;
8619
+ try {
8620
+ await this.#closePersistWriter();
8621
+ } catch {
8622
+ // A writer reports its prior write failure after a confirmed close. A
8623
+ // second close clears that terminal writer; retryable close failures are
8624
+ // genuinely re-dispatched and still fail if the descriptor cannot settle.
8625
+ }
8626
+ await this.#closePersistWriter();
8627
+ const inspected = inspectResumeSessionFile(sessionFile, this.storage);
8628
+ if ("kind" in inspected) throw persistenceError;
8629
+ try {
8630
+ fsyncResumeSessionIdentity(inspected.identity);
8631
+ } catch {
8632
+ throw persistenceError;
8633
+ }
8634
+ const durable = inspectResumeSessionFile(sessionFile, this.storage);
8635
+ if ("kind" in durable || !sameResumeIdentity(inspected.identity, durable.identity)) {
8636
+ throw persistenceError;
8637
+ }
8638
+ const adoption = {
8639
+ canonicalPath: durable.identity.canonicalPath,
8640
+ identity: durable.identity,
8641
+ inspection: durable,
8642
+ };
8643
+ this.#pendingStrictAdoption = adoption;
8644
+ try {
8645
+ await this.setSessionFile(durable.identity.canonicalPath);
8646
+ } finally {
8647
+ if (this.#pendingStrictAdoption === adoption) this.#pendingStrictAdoption = undefined;
8648
+ }
8649
+ }
8516
8650
  /**
8517
8651
  * Rewrite the session file after in-place entry updates.
8518
8652
  * Use sparingly (e.g., pruning old tool outputs).
@@ -28,6 +28,7 @@ export interface SessionStorageStat {
28
28
  size: number;
29
29
  mtimeMs: number;
30
30
  mtimeNs: bigint;
31
+ ctimeNs: bigint;
31
32
  mtime: Date;
32
33
  isFile: boolean;
33
34
  }
@@ -47,6 +48,7 @@ function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
47
48
  size: Number(stats.size),
48
49
  mtimeMs: Number(stats.mtimeMs),
49
50
  mtimeNs: stats.mtimeNs,
51
+ ctimeNs: stats.ctimeNs,
50
52
  mtime: stats.mtime,
51
53
  isFile: stats.isFile(),
52
54
  };
@@ -1624,6 +1626,7 @@ export class MemorySessionStorage implements SessionStorage {
1624
1626
  size: entry.content.byteLength,
1625
1627
  mtimeMs: entry.mtimeMs,
1626
1628
  mtimeNs: BigInt(entry.mtimeMs) * 1_000_000n,
1629
+ ctimeNs: BigInt(entry.mtimeMs) * 1_000_000n,
1627
1630
  mtime: new Date(entry.mtimeMs),
1628
1631
  isFile: true,
1629
1632
  };