deepline 0.3.202 → 0.3.204

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.
@@ -200,7 +200,7 @@ export const SDK_RELEASE = {
200
200
  // getters keep their established compatibility behavior.
201
201
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
202
202
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
203
- version: '0.3.202',
203
+ version: '0.3.204',
204
204
  updateSummary:
205
205
  'Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.',
206
206
  packageCapabilities: {
@@ -1,16 +1,73 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
 
3
- function run(command: string, args: string[], quiet = false): boolean {
4
- const result = spawnSync(command, args, {
3
+ const FLY_REGISTRY_COMMAND_TIMEOUT_MS = 30_000;
4
+ const RUNTIME_IMAGE_MANIFEST_TIMEOUT_MS = 10_000;
5
+
6
+ type FlyRegistryCommandResult = Readonly<{
7
+ error?: Error & { code?: string };
8
+ status: number | null;
9
+ stdout?: string | null;
10
+ stderr?: string | null;
11
+ }>;
12
+
13
+ type FlyRegistryCommandSpawn = (
14
+ command: string,
15
+ args: readonly string[],
16
+ options: Readonly<{
17
+ encoding: 'utf8';
18
+ killSignal: 'SIGKILL';
19
+ stdio: 'inherit' | 'pipe';
20
+ timeout: number;
21
+ }>,
22
+ ) => FlyRegistryCommandResult;
23
+
24
+ function run(
25
+ command: string,
26
+ args: string[],
27
+ quiet = false,
28
+ timeoutMs = FLY_REGISTRY_COMMAND_TIMEOUT_MS,
29
+ spawnOverride?: FlyRegistryCommandSpawn,
30
+ ): boolean {
31
+ const spawnCommand: FlyRegistryCommandSpawn =
32
+ spawnOverride ??
33
+ ((executable, commandArgs, spawnOptions) =>
34
+ spawnSync(executable, [...commandArgs], spawnOptions));
35
+ const result = spawnCommand(command, args, {
5
36
  encoding: 'utf8',
37
+ killSignal: 'SIGKILL',
6
38
  stdio: quiet ? 'pipe' : 'inherit',
39
+ timeout: timeoutMs,
7
40
  });
41
+ if (result.error) {
42
+ const error = result.error as NodeJS.ErrnoException;
43
+ if (error.code === 'ETIMEDOUT') {
44
+ throw Object.assign(
45
+ new Error(
46
+ `[fly-registry] ${command} ${args.join(' ')} timed out after ${timeoutMs}ms.`,
47
+ ),
48
+ { code: error.code },
49
+ );
50
+ }
51
+ throw result.error;
52
+ }
8
53
  return result.status === 0;
9
54
  }
10
55
 
11
56
  /** Shared immutable-tag presence probe for image builds and rollback planning. */
12
- export function runtimeImageManifestExists(image: string): boolean {
13
- return run('docker', ['manifest', 'inspect', image], true);
57
+ export function runtimeImageManifestExists(
58
+ image: string,
59
+ options: {
60
+ timeoutMs?: number;
61
+ spawnCommand?: FlyRegistryCommandSpawn;
62
+ } = {},
63
+ ): boolean {
64
+ return run(
65
+ 'docker',
66
+ ['manifest', 'inspect', image],
67
+ true,
68
+ options.timeoutMs ?? RUNTIME_IMAGE_MANIFEST_TIMEOUT_MS,
69
+ options.spawnCommand,
70
+ );
14
71
  }
15
72
 
16
73
  export const FLY_REGISTRY_AUTH_ATTEMPTS = 3;
@@ -25,13 +82,37 @@ const FLY_REGISTRY_AUTH_RETRY_DELAY_MS = 1_500;
25
82
  */
26
83
  export function authenticateFlyRegistry(
27
84
  sleep: (ms: number) => void = sleepSync,
85
+ spawnOverride?: FlyRegistryCommandSpawn,
28
86
  ): void {
87
+ let lastTimeoutError: Error | undefined;
29
88
  for (let attempt = 1; attempt <= FLY_REGISTRY_AUTH_ATTEMPTS; attempt += 1) {
30
- if (run('flyctl', ['auth', 'docker'])) return;
89
+ try {
90
+ if (
91
+ run(
92
+ 'flyctl',
93
+ ['auth', 'docker'],
94
+ false,
95
+ FLY_REGISTRY_COMMAND_TIMEOUT_MS,
96
+ spawnOverride,
97
+ )
98
+ ) {
99
+ return;
100
+ }
101
+ lastTimeoutError = undefined;
102
+ } catch (error) {
103
+ if (
104
+ !(error instanceof Error) ||
105
+ (error as NodeJS.ErrnoException).code !== 'ETIMEDOUT'
106
+ ) {
107
+ throw error;
108
+ }
109
+ lastTimeoutError = error;
110
+ }
31
111
  if (attempt < FLY_REGISTRY_AUTH_ATTEMPTS) {
32
112
  sleep(FLY_REGISTRY_AUTH_RETRY_DELAY_MS * attempt);
33
113
  }
34
114
  }
115
+ if (lastTimeoutError) throw lastTimeoutError;
35
116
  throw new Error(
36
117
  `Fly registry authentication failed after ${FLY_REGISTRY_AUTH_ATTEMPTS} attempts.`,
37
118
  );
@@ -307,7 +307,7 @@ type BreakerState = {
307
307
  consecutiveFailures: number;
308
308
  openedAt: number | null;
309
309
  lastFailureMessage: string;
310
- halfOpenInFlight: boolean;
310
+ halfOpenProbe: symbol | null;
311
311
  };
312
312
 
313
313
  export type RuntimeSchedulerPoolPriority = 'ordinary' | 'critical';
@@ -645,6 +645,9 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
645
645
  maxAcquireQueueDepth,
646
646
  maxAcquireQueueWaitMs,
647
647
  );
648
+ // A pool created by the old breaker used a boolean claim. The new token
649
+ // does not inherit a stranded half-open claim across a hot reload.
650
+ existing.breaker.halfOpenProbe ??= null;
648
651
  return existing;
649
652
  }
650
653
 
@@ -671,7 +674,7 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
671
674
  consecutiveFailures: 0,
672
675
  openedAt: null,
673
676
  lastFailureMessage: '',
674
- halfOpenInFlight: false,
677
+ halfOpenProbe: null,
675
678
  },
676
679
  stats: {
677
680
  acquires: 0,
@@ -698,31 +701,39 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
698
701
  return entry;
699
702
  }
700
703
 
701
- function breakerGate(entry: PoolEntry): void {
704
+ function breakerGate(entry: PoolEntry): symbol | null {
702
705
  const { breaker } = entry;
703
- if (breaker.openedAt === null) return;
706
+ if (breaker.openedAt === null) return null;
704
707
  const elapsed = Date.now() - breaker.openedAt;
705
708
  if (elapsed < RUNTIME_SCHEDULER_POOL_DEFAULTS.breaker.openMs) {
706
709
  entry.stats.breakerRejections += 1;
707
710
  throw new RuntimeSchedulerCircuitOpenError(breaker.lastFailureMessage);
708
711
  }
709
712
  // Half-open: let exactly one caller through as the trial.
710
- if (breaker.halfOpenInFlight) {
713
+ if (breaker.halfOpenProbe !== null) {
711
714
  entry.stats.breakerRejections += 1;
712
715
  throw new RuntimeSchedulerCircuitOpenError(breaker.lastFailureMessage);
713
716
  }
714
- breaker.halfOpenInFlight = true;
717
+ const probe = Symbol('runtime-scheduler-half-open-probe');
718
+ breaker.halfOpenProbe = probe;
719
+ return probe;
715
720
  }
716
721
 
717
722
  function recordBreakerSuccess(entry: PoolEntry): void {
718
723
  entry.breaker.consecutiveFailures = 0;
719
724
  entry.breaker.openedAt = null;
720
- entry.breaker.halfOpenInFlight = false;
725
+ entry.breaker.halfOpenProbe = null;
721
726
  }
722
727
 
723
- function recordBreakerFailure(entry: PoolEntry, error: unknown): void {
728
+ function recordBreakerFailure(
729
+ entry: PoolEntry,
730
+ error: unknown,
731
+ probe: symbol | null,
732
+ ): void {
724
733
  const { breaker } = entry;
725
- breaker.halfOpenInFlight = false;
734
+ if (probe !== null && breaker.halfOpenProbe === probe) {
735
+ breaker.halfOpenProbe = null;
736
+ }
726
737
  breaker.consecutiveFailures += 1;
727
738
  breaker.lastFailureMessage =
728
739
  error instanceof Error ? error.message : String(error);
@@ -798,190 +809,46 @@ export async function withRuntimeSchedulerClient<T>(
798
809
  throwIfSchedulerAborted(signal);
799
810
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
800
811
  throwIfSchedulerAborted(signal);
801
- breakerGate(entry);
802
- if (entry.pool.waitingCount >= entry.maxAcquireQueueDepth) {
803
- entry.stats.saturationRejections += 1;
804
- throw new RuntimeSchedulerSaturatedError(entry.pool.waitingCount);
805
- }
806
- const acquireStart = performance.now();
807
- entry.stats.peakTotalClients = Math.max(
808
- entry.stats.peakTotalClients,
809
- entry.pool.totalCount,
810
- );
811
- entry.stats.peakWaitingClients = Math.max(
812
- entry.stats.peakWaitingClients,
813
- entry.pool.waitingCount,
814
- );
815
- let poolClient;
812
+ const halfOpenProbe = breakerGate(entry);
813
+ let capacityObserved = false;
816
814
  try {
817
- poolClient = await entry.pool.connect();
818
- } catch (error) {
819
- if (isRuntimeSchedulerLocalAcquireTimeout(error)) {
815
+ if (entry.pool.waitingCount >= entry.maxAcquireQueueDepth) {
820
816
  entry.stats.saturationRejections += 1;
821
- console.warn('[runtime-scheduler-pool.local_acquire_saturated]', {
822
- maxClients: entry.maxClients,
823
- totalClients: entry.pool.totalCount,
824
- idleClients: entry.pool.idleCount,
825
- waitingClients: entry.pool.waitingCount,
826
- });
827
817
  throw new RuntimeSchedulerSaturatedError(entry.pool.waitingCount);
828
818
  }
829
- if (isPgBouncerCapacityError(error)) {
830
- recordCapacityRejection(entry, error);
831
- throw new RuntimeSchedulerPgBouncerCapacityError(
832
- error instanceof Error ? error.message : String(error),
833
- );
834
- }
835
- recordBreakerFailure(entry, error);
836
- entry.stats.connectionErrors += 1;
837
- lastError = error;
838
- if (attempt + 1 < maxAttempts) {
839
- entry.stats.retries += 1;
840
- await sleep(jitteredBackoffMs(attempt));
841
- throwIfSchedulerAborted(signal);
842
- continue;
843
- }
844
- throw error;
845
- }
846
- entry.stats.acquires += 1;
847
- const acquireWaitMs = performance.now() - acquireStart;
848
- entry.stats.peakTotalClients = Math.max(
849
- entry.stats.peakTotalClients,
850
- entry.pool.totalCount,
851
- );
852
- pushSample(entry.stats.acquireWait, acquireWaitMs);
853
-
854
- // A checked-out pg client emits 'error' (not via the pool) when its TCP
855
- // connection dies while idle — e.g. a worker holding a client across a
856
- // long play execution. With no listener that is an uncaught exception
857
- // that kills the process (observed: worker crash mid-150k-row run).
858
- // Swallow it here; the next query on the dead client fails normally and
859
- // flows through the connection-error retry/poison path below.
860
- let checkedOutError: unknown = null;
861
- const onCheckedOutError = (error: Error) => {
862
- checkedOutError = error;
863
- entry.stats.connectionErrors += 1;
864
- console.warn('[runtime-scheduler-pool.checked_out_client_error]', {
865
- error: error.message,
866
- });
867
- };
868
- poolClient.on('error', onCheckedOutError);
869
- let clientReleased = false;
870
- const releasePoolClient = (error?: Error) => {
871
- if (clientReleased) return;
872
- clientReleased = true;
873
- poolClient.release(error);
874
- };
875
- const onAbort = () => releasePoolClient(schedulerAbortReason(signal!));
876
- signal?.addEventListener('abort', onAbort, { once: true });
877
-
878
- let queryCount = 0;
879
- let queryMs = 0;
880
- try {
881
- throwIfSchedulerAborted(signal);
882
- const result = await fn({
883
- query: async (sql, params) => {
884
- throwIfSchedulerAborted(signal);
885
- const queryStartedAt = performance.now();
886
- queryCount += 1;
887
- try {
888
- try {
889
- const queryResult = await poolClient.query(sql, params);
890
- throwIfSchedulerAborted(signal);
891
- return { rows: queryResult.rows };
892
- } catch (error) {
893
- // node-postgres' query timeout and an AbortSignal reject the
894
- // promise, but the server may still be processing the SQL.
895
- // Never return that socket to the pool: doing so lets the
896
- // next retry overlap the first statement on the same backend.
897
- if (isRuntimeSchedulerQueryCancellationError(error)) {
898
- releasePoolClient(
899
- error instanceof Error ? error : new Error(String(error)),
900
- );
901
- }
902
- throw error;
903
- }
904
- } finally {
905
- queryMs += performance.now() - queryStartedAt;
906
- }
907
- },
908
- });
909
- throwIfSchedulerAborted(signal);
910
- recordBreakerSuccess(entry);
911
- signal?.removeEventListener('abort', onAbort);
912
- poolClient.removeListener('error', onCheckedOutError);
913
- // The connection died mid-fn but fn happened to finish without another
914
- // query — destroy the dead connection rather than pooling it.
915
- releasePoolClient(
916
- checkedOutError instanceof Error ? checkedOutError : undefined,
819
+ const acquireStart = performance.now();
820
+ entry.stats.peakTotalClients = Math.max(
821
+ entry.stats.peakTotalClients,
822
+ entry.pool.totalCount,
917
823
  );
918
- if (trace) {
919
- console.info(
920
- '[runtime-scheduler-pool.operation] ' +
921
- JSON.stringify({
922
- runId: trace.runId,
923
- phase: trace.phase,
924
- outcome: 'success',
925
- attempt: attempt + 1,
926
- priority: trace.priority ?? 'ordinary',
927
- admissionWaitMs,
928
- acquireWaitMs,
929
- queryCount,
930
- queryMs,
931
- totalMs: performance.now() - operationStartedAt,
932
- }),
933
- );
934
- }
935
- return result;
936
- } catch (error) {
937
- if (trace) {
938
- console.info(
939
- '[runtime-scheduler-pool.operation] ' +
940
- JSON.stringify({
941
- runId: trace.runId,
942
- phase: trace.phase,
943
- outcome: 'failed',
944
- attempt: attempt + 1,
945
- priority: trace.priority ?? 'ordinary',
946
- admissionWaitMs,
947
- acquireWaitMs,
948
- queryCount,
949
- queryMs,
950
- totalMs: performance.now() - operationStartedAt,
951
- errorName: error instanceof Error ? error.name : 'UnknownError',
952
- }),
953
- );
954
- }
955
- signal?.removeEventListener('abort', onAbort);
956
- poolClient.removeListener('error', onCheckedOutError);
957
- if (signal?.aborted) {
958
- const cancellation = schedulerAbortReason(signal);
959
- releasePoolClient(cancellation);
960
- throw cancellation;
961
- }
962
- if (isPgBouncerCapacityError(error)) {
963
- recordCapacityRejection(entry, error);
964
- releasePoolClient();
965
- throw new RuntimeSchedulerPgBouncerCapacityError(
966
- error instanceof Error ? error.message : String(error),
967
- );
968
- }
969
- if (isRuntimeSchedulerQueryCancellationError(error)) {
970
- entry.stats.queryErrors += 1;
971
- recordBreakerSuccess(entry);
972
- releasePoolClient(
973
- error instanceof Error ? error : new Error(String(error)),
974
- );
975
- throw error;
976
- }
977
- if (isConnectionCategoryError(error)) {
978
- recordBreakerFailure(entry, error);
824
+ entry.stats.peakWaitingClients = Math.max(
825
+ entry.stats.peakWaitingClients,
826
+ entry.pool.waitingCount,
827
+ );
828
+ let poolClient;
829
+ try {
830
+ poolClient = await entry.pool.connect();
831
+ } catch (error) {
832
+ if (isRuntimeSchedulerLocalAcquireTimeout(error)) {
833
+ entry.stats.saturationRejections += 1;
834
+ console.warn('[runtime-scheduler-pool.local_acquire_saturated]', {
835
+ maxClients: entry.maxClients,
836
+ totalClients: entry.pool.totalCount,
837
+ idleClients: entry.pool.idleCount,
838
+ waitingClients: entry.pool.waitingCount,
839
+ });
840
+ throw new RuntimeSchedulerSaturatedError(entry.pool.waitingCount);
841
+ }
842
+ if (isPgBouncerCapacityError(error)) {
843
+ recordCapacityRejection(entry, error);
844
+ capacityObserved = true;
845
+ throw new RuntimeSchedulerPgBouncerCapacityError(
846
+ error instanceof Error ? error.message : String(error),
847
+ );
848
+ }
849
+ recordBreakerFailure(entry, error, halfOpenProbe);
979
850
  entry.stats.connectionErrors += 1;
980
851
  lastError = error;
981
- // Destroy the broken connection instead of returning it to the pool.
982
- releasePoolClient(
983
- error instanceof Error ? error : new Error(String(error)),
984
- );
985
852
  if (attempt + 1 < maxAttempts) {
986
853
  entry.stats.retries += 1;
987
854
  await sleep(jitteredBackoffMs(attempt));
@@ -990,30 +857,197 @@ export async function withRuntimeSchedulerClient<T>(
990
857
  }
991
858
  throw error;
992
859
  }
993
- if (isRuntimeSchedulerRetryableTransactionError(error)) {
994
- // PostgreSQL has aborted the transaction, so never return this
995
- // client to the pool. Re-run the idempotent scheduler operation on a
996
- // new session without opening the connection-failure breaker.
997
- entry.stats.queryErrors += 1;
860
+ entry.stats.acquires += 1;
861
+ const acquireWaitMs = performance.now() - acquireStart;
862
+ entry.stats.peakTotalClients = Math.max(
863
+ entry.stats.peakTotalClients,
864
+ entry.pool.totalCount,
865
+ );
866
+ pushSample(entry.stats.acquireWait, acquireWaitMs);
867
+
868
+ // A checked-out pg client emits 'error' (not via the pool) when its TCP
869
+ // connection dies while idle — e.g. a worker holding a client across a
870
+ // long play execution. With no listener that is an uncaught exception
871
+ // that kills the process (observed: worker crash mid-150k-row run).
872
+ // Swallow it here; the next query on the dead client fails normally and
873
+ // flows through the connection-error retry/poison path below.
874
+ let checkedOutError: unknown = null;
875
+ const onCheckedOutError = (error: Error) => {
876
+ checkedOutError = error;
877
+ entry.stats.connectionErrors += 1;
878
+ console.warn('[runtime-scheduler-pool.checked_out_client_error]', {
879
+ error: error.message,
880
+ });
881
+ };
882
+ poolClient.on('error', onCheckedOutError);
883
+ let clientReleased = false;
884
+ const releasePoolClient = (error?: Error) => {
885
+ if (clientReleased) return;
886
+ clientReleased = true;
887
+ poolClient.release(error);
888
+ };
889
+ const onAbort = () => releasePoolClient(schedulerAbortReason(signal!));
890
+ signal?.addEventListener('abort', onAbort, { once: true });
891
+
892
+ let queryCount = 0;
893
+ let queryMs = 0;
894
+ try {
895
+ throwIfSchedulerAborted(signal);
896
+ const result = await fn({
897
+ query: async (sql, params) => {
898
+ throwIfSchedulerAborted(signal);
899
+ const queryStartedAt = performance.now();
900
+ queryCount += 1;
901
+ try {
902
+ try {
903
+ const queryResult = await poolClient.query(sql, params);
904
+ throwIfSchedulerAborted(signal);
905
+ return { rows: queryResult.rows };
906
+ } catch (error) {
907
+ // node-postgres' query timeout and an AbortSignal reject the
908
+ // promise, but the server may still be processing the SQL.
909
+ // Never return that socket to the pool: doing so lets the
910
+ // next retry overlap the first statement on the same backend.
911
+ if (isRuntimeSchedulerQueryCancellationError(error)) {
912
+ releasePoolClient(
913
+ error instanceof Error ? error : new Error(String(error)),
914
+ );
915
+ }
916
+ throw error;
917
+ }
918
+ } finally {
919
+ queryMs += performance.now() - queryStartedAt;
920
+ }
921
+ },
922
+ });
923
+ throwIfSchedulerAborted(signal);
998
924
  recordBreakerSuccess(entry);
999
- lastError = error;
925
+ signal?.removeEventListener('abort', onAbort);
926
+ poolClient.removeListener('error', onCheckedOutError);
927
+ // The connection died mid-fn but fn happened to finish without another
928
+ // query — destroy the dead connection rather than pooling it.
1000
929
  releasePoolClient(
1001
- error instanceof Error ? error : new Error(String(error)),
930
+ checkedOutError instanceof Error ? checkedOutError : undefined,
1002
931
  );
1003
- if (attempt + 1 < maxAttempts) {
1004
- entry.stats.retries += 1;
1005
- await sleep(jitteredBackoffMs(attempt));
1006
- throwIfSchedulerAborted(signal);
1007
- continue;
932
+ if (trace) {
933
+ console.info(
934
+ '[runtime-scheduler-pool.operation] ' +
935
+ JSON.stringify({
936
+ runId: trace.runId,
937
+ phase: trace.phase,
938
+ outcome: 'success',
939
+ attempt: attempt + 1,
940
+ priority: trace.priority ?? 'ordinary',
941
+ admissionWaitMs,
942
+ acquireWaitMs,
943
+ queryCount,
944
+ queryMs,
945
+ totalMs: performance.now() - operationStartedAt,
946
+ }),
947
+ );
1008
948
  }
949
+ return result;
950
+ } catch (error) {
951
+ if (trace) {
952
+ console.info(
953
+ '[runtime-scheduler-pool.operation] ' +
954
+ JSON.stringify({
955
+ runId: trace.runId,
956
+ phase: trace.phase,
957
+ outcome: 'failed',
958
+ attempt: attempt + 1,
959
+ priority: trace.priority ?? 'ordinary',
960
+ admissionWaitMs,
961
+ acquireWaitMs,
962
+ queryCount,
963
+ queryMs,
964
+ totalMs: performance.now() - operationStartedAt,
965
+ errorName:
966
+ error instanceof Error ? error.name : 'UnknownError',
967
+ }),
968
+ );
969
+ }
970
+ signal?.removeEventListener('abort', onAbort);
971
+ poolClient.removeListener('error', onCheckedOutError);
972
+ if (signal?.aborted) {
973
+ const cancellation = schedulerAbortReason(signal);
974
+ releasePoolClient(cancellation);
975
+ throw cancellation;
976
+ }
977
+ if (isPgBouncerCapacityError(error)) {
978
+ recordCapacityRejection(entry, error);
979
+ capacityObserved = true;
980
+ releasePoolClient();
981
+ throw new RuntimeSchedulerPgBouncerCapacityError(
982
+ error instanceof Error ? error.message : String(error),
983
+ );
984
+ }
985
+ if (isRuntimeSchedulerQueryCancellationError(error)) {
986
+ entry.stats.queryErrors += 1;
987
+ recordBreakerSuccess(entry);
988
+ releasePoolClient(
989
+ error instanceof Error ? error : new Error(String(error)),
990
+ );
991
+ throw error;
992
+ }
993
+ if (isConnectionCategoryError(error)) {
994
+ recordBreakerFailure(entry, error, halfOpenProbe);
995
+ entry.stats.connectionErrors += 1;
996
+ lastError = error;
997
+ // Destroy the broken connection instead of returning it to the pool.
998
+ releasePoolClient(
999
+ error instanceof Error ? error : new Error(String(error)),
1000
+ );
1001
+ if (attempt + 1 < maxAttempts) {
1002
+ entry.stats.retries += 1;
1003
+ await sleep(jitteredBackoffMs(attempt));
1004
+ throwIfSchedulerAborted(signal);
1005
+ continue;
1006
+ }
1007
+ throw error;
1008
+ }
1009
+ if (isRuntimeSchedulerRetryableTransactionError(error)) {
1010
+ // PostgreSQL has aborted the transaction, so never return this
1011
+ // client to the pool. Re-run the idempotent scheduler operation on a
1012
+ // new session without opening the connection-failure breaker.
1013
+ entry.stats.queryErrors += 1;
1014
+ recordBreakerSuccess(entry);
1015
+ lastError = error;
1016
+ releasePoolClient(
1017
+ error instanceof Error ? error : new Error(String(error)),
1018
+ );
1019
+ if (attempt + 1 < maxAttempts) {
1020
+ entry.stats.retries += 1;
1021
+ await sleep(jitteredBackoffMs(attempt));
1022
+ throwIfSchedulerAborted(signal);
1023
+ continue;
1024
+ }
1025
+ throw error;
1026
+ }
1027
+ // Query-level failure on a healthy connection: count it, return the
1028
+ // connection to the pool, surface the error without retry.
1029
+ entry.stats.queryErrors += 1;
1030
+ recordBreakerSuccess(entry);
1031
+ releasePoolClient();
1009
1032
  throw error;
1010
1033
  }
1011
- // Query-level failure on a healthy connection: count it, return the
1012
- // connection to the pool, surface the error without retry.
1013
- entry.stats.queryErrors += 1;
1014
- recordBreakerSuccess(entry);
1015
- releasePoolClient();
1016
- throw error;
1034
+ } finally {
1035
+ // A canceled, locally saturated, or capacity-rejected trial did not
1036
+ // call either breaker recorder. Never let it own the probe forever.
1037
+ // Only the trial that claimed this token may release it: another
1038
+ // operation can close and reopen the breaker while this one awaits.
1039
+ if (
1040
+ halfOpenProbe !== null &&
1041
+ entry.breaker.halfOpenProbe === halfOpenProbe
1042
+ ) {
1043
+ entry.breaker.halfOpenProbe = null;
1044
+ if (capacityObserved && entry.breaker.openedAt !== null) {
1045
+ // Capacity is not a connection failure. Keep the existing open
1046
+ // state and try once more after the ordinary cooldown instead of
1047
+ // probing the still-full database on every request.
1048
+ entry.breaker.openedAt = Date.now();
1049
+ }
1050
+ }
1017
1051
  }
1018
1052
  }
1019
1053
  throw lastError instanceof Error
@@ -239,7 +239,7 @@ function defaultThreshold(
239
239
  }
240
240
 
241
241
  function formatCredits(value: number): string {
242
- return value.toLocaleString('en-US', { maximumFractionDigits: 2 });
242
+ return value.toLocaleString('en-US', { maximumFractionDigits: 3 });
243
243
  }
244
244
 
245
245
  export const DEEPLINE_DOCS_URL = 'https://deepline.com/docs';
package/dist/cli/index.js CHANGED
@@ -3051,7 +3051,7 @@ var SDK_RELEASE = {
3051
3051
  // getters keep their established compatibility behavior.
3052
3052
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
3053
3053
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
3054
- version: "0.3.202",
3054
+ version: "0.3.204",
3055
3055
  updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.",
3056
3056
  packageCapabilities: {
3057
3057
  updatePreferences: 1
@@ -29298,7 +29298,12 @@ async function handleRunStop(args) {
29298
29298
  }
29299
29299
  const client2 = new DeeplineClient();
29300
29300
  const result = await client2.runs.stop(runId, { reason });
29301
- if (result.cancelling === true) {
29301
+ if (result.cancelling === true && result.hitlCancellationPending !== true) {
29302
+ const lines2 = [
29303
+ `Cancellation queued for ${result.runId || runId}; sandbox cleanup is pending`,
29304
+ ...result.hitlCancelledCount > 0 ? [`cancelled HITL waits: ${result.hitlCancelledCount}`] : [],
29305
+ `inspect: deepline runs get ${runId} --full --json`
29306
+ ];
29302
29307
  printCommandEnvelope(
29303
29308
  {
29304
29309
  ...result,
@@ -29306,14 +29311,7 @@ async function handleRunStop(args) {
29306
29311
  sections: [
29307
29312
  {
29308
29313
  title: "run stop",
29309
- lines: [
29310
- `Cancellation queued for ${result.runId || runId}; sandbox cleanup is pending`,
29311
- ...result.hitlCancelledCount > 0 ? [`cancelled HITL waits: ${result.hitlCancelledCount}`] : [],
29312
- ...result.hitlCancellationPending === true ? [
29313
- "warning: HITL cancellation or same-card Slack refresh is still pending"
29314
- ] : [],
29315
- `inspect: deepline runs get ${runId} --full --json`
29316
- ]
29314
+ lines: lines2
29317
29315
  }
29318
29316
  ]
29319
29317
  }
@@ -50804,8 +50802,8 @@ function readPendingToolExecution(path) {
50804
50802
  }
50805
50803
  return saved.idempotencyKey;
50806
50804
  }
50807
- async function completePendingToolExecution(path) {
50808
- if (!path || !(0, import_node_fs26.existsSync)(path)) return;
50805
+ async function completePendingToolExecution(path, idempotencyKey) {
50806
+ if (!path || !idempotencyKey || !(0, import_node_fs26.existsSync)(path)) return;
50809
50807
  await new Promise((resolve22, reject) => {
50810
50808
  const onError = (error) => {
50811
50809
  process.stdout.off("error", onError);
@@ -50821,7 +50819,12 @@ async function completePendingToolExecution(path) {
50821
50819
  }
50822
50820
  });
50823
50821
  });
50824
- if ((0, import_node_fs26.existsSync)(path)) (0, import_node_fs26.unlinkSync)(path);
50822
+ try {
50823
+ if (readPendingToolExecution(path) !== idempotencyKey) return;
50824
+ (0, import_node_fs26.unlinkSync)(path);
50825
+ } catch (error) {
50826
+ if (error.code !== "ENOENT") throw error;
50827
+ }
50825
50828
  }
50826
50829
  function safeFileStem(value) {
50827
50830
  return value.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "tool";
@@ -51169,7 +51172,7 @@ async function executeTool(args) {
51169
51172
  const executeExitCode = toolExecuteExitCode(baseEnvelope);
51170
51173
  if (!parsed.outPath && (parsed.outputFormat === "json" || parsed.outputFormat === "auto" && shouldEmitJson())) {
51171
51174
  printCommandEnvelope(baseEnvelope, { json: true });
51172
- await completePendingToolExecution(pendingRecoveryPath);
51175
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51173
51176
  return executeExitCode;
51174
51177
  }
51175
51178
  if (parsed.outputFormat === "json_file") {
@@ -51187,7 +51190,7 @@ async function executeTool(args) {
51187
51190
  },
51188
51191
  { json: true }
51189
51192
  );
51190
- await completePendingToolExecution(pendingRecoveryPath);
51193
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51191
51194
  return executeExitCode;
51192
51195
  }
51193
51196
  if (!listConversion) {
@@ -51216,11 +51219,11 @@ async function executeTool(args) {
51216
51219
  },
51217
51220
  { json: parsed.outputFormat === "csv_file" || shouldEmitJson() }
51218
51221
  );
51219
- await completePendingToolExecution(pendingRecoveryPath);
51222
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51220
51223
  return executeExitCode;
51221
51224
  }
51222
51225
  printCommandEnvelope(baseEnvelope, { json: false });
51223
- await completePendingToolExecution(pendingRecoveryPath);
51226
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51224
51227
  return executeExitCode;
51225
51228
  }
51226
51229
  const rowOutput = projectRowOutput(listConversion);
@@ -51243,7 +51246,7 @@ async function executeTool(args) {
51243
51246
  });
51244
51247
  if (parsed.outputFormat === "csv_file") {
51245
51248
  printCommandEnvelope(materializedEnvelope, { json: true });
51246
- await completePendingToolExecution(pendingRecoveryPath);
51249
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51247
51250
  return executeExitCode;
51248
51251
  }
51249
51252
  if (parsed.outPath) {
@@ -51252,7 +51255,7 @@ async function executeTool(args) {
51252
51255
  text: `Wrote ${csv.rowCount} row(s) to ${csv.path}
51253
51256
  `
51254
51257
  });
51255
- await completePendingToolExecution(pendingRecoveryPath);
51258
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51256
51259
  return executeExitCode;
51257
51260
  }
51258
51261
  if (parsed.noPreview) {
@@ -51267,11 +51270,11 @@ async function executeTool(args) {
51267
51270
  { json: shouldEmitJson(), text: `${csv.path}
51268
51271
  ` }
51269
51272
  );
51270
- await completePendingToolExecution(pendingRecoveryPath);
51273
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51271
51274
  return executeExitCode;
51272
51275
  }
51273
51276
  printCommandEnvelope(materializedEnvelope, { json: false });
51274
- await completePendingToolExecution(pendingRecoveryPath);
51277
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51275
51278
  return executeExitCode;
51276
51279
  }
51277
51280
 
@@ -3046,7 +3046,7 @@ var SDK_RELEASE = {
3046
3046
  // getters keep their established compatibility behavior.
3047
3047
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
3048
3048
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
3049
- version: "0.3.202",
3049
+ version: "0.3.204",
3050
3050
  updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.",
3051
3051
  packageCapabilities: {
3052
3052
  updatePreferences: 1
@@ -29371,7 +29371,12 @@ async function handleRunStop(args) {
29371
29371
  }
29372
29372
  const client2 = new DeeplineClient();
29373
29373
  const result = await client2.runs.stop(runId, { reason });
29374
- if (result.cancelling === true) {
29374
+ if (result.cancelling === true && result.hitlCancellationPending !== true) {
29375
+ const lines2 = [
29376
+ `Cancellation queued for ${result.runId || runId}; sandbox cleanup is pending`,
29377
+ ...result.hitlCancelledCount > 0 ? [`cancelled HITL waits: ${result.hitlCancelledCount}`] : [],
29378
+ `inspect: deepline runs get ${runId} --full --json`
29379
+ ];
29375
29380
  printCommandEnvelope(
29376
29381
  {
29377
29382
  ...result,
@@ -29379,14 +29384,7 @@ async function handleRunStop(args) {
29379
29384
  sections: [
29380
29385
  {
29381
29386
  title: "run stop",
29382
- lines: [
29383
- `Cancellation queued for ${result.runId || runId}; sandbox cleanup is pending`,
29384
- ...result.hitlCancelledCount > 0 ? [`cancelled HITL waits: ${result.hitlCancelledCount}`] : [],
29385
- ...result.hitlCancellationPending === true ? [
29386
- "warning: HITL cancellation or same-card Slack refresh is still pending"
29387
- ] : [],
29388
- `inspect: deepline runs get ${runId} --full --json`
29389
- ]
29387
+ lines: lines2
29390
29388
  }
29391
29389
  ]
29392
29390
  }
@@ -50952,8 +50950,8 @@ function readPendingToolExecution(path) {
50952
50950
  }
50953
50951
  return saved.idempotencyKey;
50954
50952
  }
50955
- async function completePendingToolExecution(path) {
50956
- if (!path || !existsSync19(path)) return;
50953
+ async function completePendingToolExecution(path, idempotencyKey) {
50954
+ if (!path || !idempotencyKey || !existsSync19(path)) return;
50957
50955
  await new Promise((resolve22, reject) => {
50958
50956
  const onError = (error) => {
50959
50957
  process.stdout.off("error", onError);
@@ -50969,7 +50967,12 @@ async function completePendingToolExecution(path) {
50969
50967
  }
50970
50968
  });
50971
50969
  });
50972
- if (existsSync19(path)) unlinkSync4(path);
50970
+ try {
50971
+ if (readPendingToolExecution(path) !== idempotencyKey) return;
50972
+ unlinkSync4(path);
50973
+ } catch (error) {
50974
+ if (error.code !== "ENOENT") throw error;
50975
+ }
50973
50976
  }
50974
50977
  function safeFileStem(value) {
50975
50978
  return value.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "tool";
@@ -51317,7 +51320,7 @@ async function executeTool(args) {
51317
51320
  const executeExitCode = toolExecuteExitCode(baseEnvelope);
51318
51321
  if (!parsed.outPath && (parsed.outputFormat === "json" || parsed.outputFormat === "auto" && shouldEmitJson())) {
51319
51322
  printCommandEnvelope(baseEnvelope, { json: true });
51320
- await completePendingToolExecution(pendingRecoveryPath);
51323
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51321
51324
  return executeExitCode;
51322
51325
  }
51323
51326
  if (parsed.outputFormat === "json_file") {
@@ -51335,7 +51338,7 @@ async function executeTool(args) {
51335
51338
  },
51336
51339
  { json: true }
51337
51340
  );
51338
- await completePendingToolExecution(pendingRecoveryPath);
51341
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51339
51342
  return executeExitCode;
51340
51343
  }
51341
51344
  if (!listConversion) {
@@ -51364,11 +51367,11 @@ async function executeTool(args) {
51364
51367
  },
51365
51368
  { json: parsed.outputFormat === "csv_file" || shouldEmitJson() }
51366
51369
  );
51367
- await completePendingToolExecution(pendingRecoveryPath);
51370
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51368
51371
  return executeExitCode;
51369
51372
  }
51370
51373
  printCommandEnvelope(baseEnvelope, { json: false });
51371
- await completePendingToolExecution(pendingRecoveryPath);
51374
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51372
51375
  return executeExitCode;
51373
51376
  }
51374
51377
  const rowOutput = projectRowOutput(listConversion);
@@ -51391,7 +51394,7 @@ async function executeTool(args) {
51391
51394
  });
51392
51395
  if (parsed.outputFormat === "csv_file") {
51393
51396
  printCommandEnvelope(materializedEnvelope, { json: true });
51394
- await completePendingToolExecution(pendingRecoveryPath);
51397
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51395
51398
  return executeExitCode;
51396
51399
  }
51397
51400
  if (parsed.outPath) {
@@ -51400,7 +51403,7 @@ async function executeTool(args) {
51400
51403
  text: `Wrote ${csv.rowCount} row(s) to ${csv.path}
51401
51404
  `
51402
51405
  });
51403
- await completePendingToolExecution(pendingRecoveryPath);
51406
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51404
51407
  return executeExitCode;
51405
51408
  }
51406
51409
  if (parsed.noPreview) {
@@ -51415,11 +51418,11 @@ async function executeTool(args) {
51415
51418
  { json: shouldEmitJson(), text: `${csv.path}
51416
51419
  ` }
51417
51420
  );
51418
- await completePendingToolExecution(pendingRecoveryPath);
51421
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51419
51422
  return executeExitCode;
51420
51423
  }
51421
51424
  printCommandEnvelope(materializedEnvelope, { json: false });
51422
- await completePendingToolExecution(pendingRecoveryPath);
51425
+ await completePendingToolExecution(pendingRecoveryPath, idempotencyKey);
51423
51426
  return executeExitCode;
51424
51427
  }
51425
51428
 
package/dist/index.js CHANGED
@@ -864,7 +864,7 @@ var SDK_RELEASE = {
864
864
  // getters keep their established compatibility behavior.
865
865
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
866
866
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
867
- version: "0.3.202",
867
+ version: "0.3.204",
868
868
  updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.",
869
869
  packageCapabilities: {
870
870
  updatePreferences: 1
package/dist/index.mjs CHANGED
@@ -768,7 +768,7 @@ var SDK_RELEASE = {
768
768
  // getters keep their established compatibility behavior.
769
769
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
770
770
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
771
- version: "0.3.202",
771
+ version: "0.3.204",
772
772
  updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.",
773
773
  packageCapabilities: {
774
774
  updatePreferences: 1
@@ -149,7 +149,7 @@ type SdkRelease = {
149
149
  supportPolicy: SdkSupportPolicy;
150
150
  };
151
151
  declare const SDK_RELEASE: {
152
- readonly version: "0.3.202";
152
+ readonly version: "0.3.204";
153
153
  readonly updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.";
154
154
  readonly packageCapabilities: {
155
155
  readonly updatePreferences: 1;
package/dist/release.d.ts CHANGED
@@ -149,7 +149,7 @@ type SdkRelease = {
149
149
  supportPolicy: SdkSupportPolicy;
150
150
  };
151
151
  declare const SDK_RELEASE: {
152
- readonly version: "0.3.202";
152
+ readonly version: "0.3.204";
153
153
  readonly updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.";
154
154
  readonly packageCapabilities: {
155
155
  readonly updatePreferences: 1;
package/dist/release.js CHANGED
@@ -74,7 +74,7 @@ var SDK_RELEASE = {
74
74
  // getters keep their established compatibility behavior.
75
75
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
76
76
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
77
- version: "0.3.202",
77
+ version: "0.3.204",
78
78
  updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.",
79
79
  packageCapabilities: {
80
80
  updatePreferences: 1
package/dist/release.mjs CHANGED
@@ -48,7 +48,7 @@ var SDK_RELEASE = {
48
48
  // getters keep their established compatibility behavior.
49
49
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
50
50
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
51
- version: "0.3.202",
51
+ version: "0.3.204",
52
52
  updateSummary: "Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.",
53
53
  packageCapabilities: {
54
54
  updatePreferences: 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepline",
3
- "version": "0.3.202",
3
+ "version": "0.3.204",
4
4
  "description": "GTM data CLI and TypeScript SDK for coding agents",
5
5
  "license": "MIT",
6
6
  "homepage": "https://code.deepline.com",