@alipay/alipay-aipay 1.7.9 → 1.8.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.
@@ -24,6 +24,8 @@ const syncLogCode = '1005';
24
24
  const syncLogMsg = 'alipay_aipay_skill_event';
25
25
  const syncLogYuyanID = '180020010001290755';
26
26
  const uploadTimeoutMs = 3000;
27
+ const conversationUploadTimeoutMs = 10000;
28
+ const uploadWorkerLifetimeMs = 60000;
27
29
  const permissionedForegroundFlushLimit = 5;
28
30
  const localWriteMaxAttempts = 2;
29
31
  const runStateLockWaitMs = 5000;
@@ -405,11 +407,12 @@ function writePrivateJson(file, value) {
405
407
  return false;
406
408
  }
407
409
 
408
- function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env)) {
410
+ function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env), waitMs = runStateLockWaitMs) {
409
411
  const startedAt = Date.now();
412
+ let recoveredStaleLock = false;
410
413
  try {
411
414
  ensurePrivateDir(path.dirname(lockPath));
412
- while (Date.now() - startedAt < runStateLockWaitMs) {
415
+ while (true) {
413
416
  try {
414
417
  const descriptor = fs.openSync(lockPath, 'wx', 0o600);
415
418
  fs.closeSync(descriptor);
@@ -418,8 +421,13 @@ function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env)
418
421
  if (error?.code !== 'EEXIST') return '';
419
422
  try {
420
423
  const lockStat = fs.statSync(lockPath);
421
- if (Date.now() - lockStat.mtimeMs >= runStateLockStaleMs) fs.unlinkSync(lockPath);
424
+ if (!recoveredStaleLock && Date.now() - lockStat.mtimeMs >= runStateLockStaleMs) {
425
+ fs.unlinkSync(lockPath);
426
+ recoveredStaleLock = true;
427
+ continue;
428
+ }
422
429
  } catch {}
430
+ if (Date.now() - startedAt >= waitMs) return '';
423
431
  sleepSync(10);
424
432
  }
425
433
  }
@@ -427,8 +435,8 @@ function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env)
427
435
  return '';
428
436
  }
429
437
 
430
- function withRunStateLock(env = process.env, operation, requestedLockPath = runStateLockPath(env)) {
431
- const lockPath = acquireRunStateLock(env, requestedLockPath);
438
+ function withRunStateLock(env = process.env, operation, requestedLockPath = runStateLockPath(env), waitMs = runStateLockWaitMs) {
439
+ const lockPath = acquireRunStateLock(env, requestedLockPath, waitMs);
432
440
  if (!lockPath) return { acquired: false, value: null };
433
441
  try {
434
442
  return { acquired: true, value: operation() };
@@ -893,7 +901,7 @@ export function setConversationConsent({ sessionId = '', decision, inputId, conv
893
901
  }
894
902
  }
895
903
  return next;
896
- }, conversationSessionFile(ownerHash, 'index.lock', env));
904
+ }, conversationSessionFile(ownerHash, 'index.lock', env), 0);
897
905
  return transaction.acquired ? transaction.value : null;
898
906
  } catch { return null; }
899
907
  }
@@ -920,6 +928,39 @@ function readConversationState(conversationId, env, { checkOwner = true } = {})
920
928
  } catch { return null; }
921
929
  }
922
930
 
931
+ // Called only while holding the conversation's session lock. A private pending
932
+ // marker survives a crash between enqueue and the final sequence/dedup commit.
933
+ function reconcileConversationMessage(state, env) {
934
+ if (!state || !Object.prototype.hasOwnProperty.call(state, 'pending_message')) return state;
935
+ const pending = state.pending_message;
936
+ if (!pending || pending.sequence !== state.sequence + 1
937
+ || !Number.isSafeInteger(pending.sequence)
938
+ || !/^[a-f0-9]{32}$/.test(pending.message_hash || '')
939
+ || state.message_hashes.includes(pending.message_hash)
940
+ || !Number.isSafeInteger(pending.timestamp) || pending.timestamp <= 0) return null;
941
+ const basename = outboxFilename({ kind: 'conversation_message',
942
+ conversation_id: state.conversation_id, sequence: pending.sequence, timestamp: pending.timestamp });
943
+ let queued;
944
+ try {
945
+ const names = fs.readdirSync(outboxDir(env));
946
+ const name = names.find(name => name === basename || name.startsWith(`${basename}.sending.`));
947
+ if (name) {
948
+ queued = readOutboxRecord(path.join(outboxDir(env), name));
949
+ if (!queued || queued.event.conversation_id !== state.conversation_id
950
+ || queued.event.sequence !== pending.sequence || queued.event.timestamp !== pending.timestamp) return null;
951
+ }
952
+ } catch (error) {
953
+ // Missing queue means the process stopped before enqueue, or cleanup succeeded.
954
+ if (error?.code !== 'ENOENT') return null;
955
+ }
956
+ const { pending_message: _pending, ...next } = state;
957
+ if (queued) {
958
+ next.sequence = pending.sequence;
959
+ next.message_hashes = [...state.message_hashes, pending.message_hash];
960
+ }
961
+ return writePrivateJson(conversationStatePath(state.conversation_id, env), next) ? next : null;
962
+ }
963
+
923
964
  export function startConversation({ sessionId = '' } = {}, env = process.env, nowMs = Date.now()) {
924
965
  if (!telemetryEnabled(env) || !conversationCode(env)) return null;
925
966
  try {
@@ -944,7 +985,7 @@ export function startConversation({ sessionId = '' } = {}, env = process.env, no
944
985
  if (!writePrivateJson(conversationStatePath(conversationId, env), state)
945
986
  || !writePrivateJson(indexPath, { conversation_id: conversationId })) return null;
946
987
  return { ...state, created: true };
947
- }, `${indexPath}.lock`);
988
+ }, `${indexPath}.lock`, 0);
948
989
  return transaction.acquired ? transaction.value : null;
949
990
  } catch { return null; }
950
991
  }
@@ -966,7 +1007,7 @@ export function endConversation(conversationId, env = process.env) {
966
1007
  if (!state || state.lifecycle === 'CLOSED') return state;
967
1008
  const closed = { ...state, lifecycle: 'CLOSED' };
968
1009
  return writePrivateJson(file, closed) ? closed : null;
969
- }, conversationSessionFile(existing.session_hash, 'index.lock', env));
1010
+ }, conversationSessionFile(existing.session_hash, 'index.lock', env), 0);
970
1011
  return transaction.acquired ? transaction.value : null;
971
1012
  } catch { return null; }
972
1013
  }
@@ -992,7 +1033,7 @@ export async function recordConversationMessage(message, context = {}, env = pro
992
1033
  const existing = readConversationState(conversationId, env);
993
1034
  if (!existing) return null;
994
1035
  const transaction = withRunStateLock(env, () => {
995
- const state = readConversationState(conversationId, env);
1036
+ const state = reconcileConversationMessage(readConversationState(conversationId, env), env);
996
1037
  if (!state || state.lifecycle !== 'ACTIVE' || !hasConversationConsent(state, env)) return null;
997
1038
  const hash = userInputHash(`${message.role}:${messageId}`, conversationId);
998
1039
  const existingIndex = state.message_hashes.indexOf(hash);
@@ -1003,13 +1044,28 @@ export async function recordConversationMessage(message, context = {}, env = pro
1003
1044
  const event = normalizeConversationEvent({ ...conversationEventBase(state, productType, env, nowMs),
1004
1045
  sequence, role: message.role, content: sanitized.content, truncated: sanitized.truncated,
1005
1046
  has_attachments: message.has_attachments ?? false, redaction_version: 1 });
1047
+ if (!event) return null;
1048
+ // Reserve only recovery metadata, not a committed sequence or message body.
1049
+ if (!writePrivateJson(file, { ...state, pending_message: {
1050
+ sequence, message_hash: hash, timestamp: event.timestamp
1051
+ } })) return null;
1052
+ const queuedFile = writeOutboxRecord(event, env);
1053
+ if (!queuedFile) {
1054
+ writePrivateJson(file, state);
1055
+ return null;
1056
+ }
1006
1057
  const next = { ...state, sequence, message_hashes: [...state.message_hashes, hash] };
1007
- return event && writePrivateJson(file, next) ? event : null;
1008
- }, conversationSessionFile(existing.session_hash, 'index.lock', env));
1058
+ if (!writePrivateJson(file, next)) {
1059
+ try { fs.unlinkSync(queuedFile); } catch {}
1060
+ return null;
1061
+ }
1062
+ return event;
1063
+ }, conversationSessionFile(existing.session_hash, 'index.lock', env), 0);
1009
1064
  if (!transaction.acquired || !transaction.value) return null;
1010
- if (transaction.value.duplicate) return transaction.value;
1065
+ // Recovered duplicate retries may also need to wake their queued upload.
1066
+ // Already queued under the session lock; do not enqueue twice or wait for HTTP.
1011
1067
  // Never echo dialogue content in stderr markers, including in capture-only mode.
1012
- await deliverPersistedStageEvent(transaction.value, env, null);
1068
+ if (telemetryUploadEnabled(env)) spawnOutboxFlush(env, { recordStageFailure: false });
1013
1069
  return transaction.value;
1014
1070
  } catch { return null; }
1015
1071
  }
@@ -1452,30 +1508,43 @@ export function isSuccessfulHttpStatus(statusCode) {
1452
1508
  }
1453
1509
 
1454
1510
  export function sendSyncLogReportDetailed(report, {
1455
- timeoutMs = report?.item?.msg === 'alipay_aipay_skill_conversation' ? 0 : uploadTimeoutMs
1511
+ timeoutMs = report?.item?.msg === 'alipay_aipay_skill_conversation' ? conversationUploadTimeoutMs : uploadTimeoutMs,
1512
+ request = https.request
1456
1513
  } = {}) {
1457
1514
  return new Promise((resolve) => {
1458
1515
  let settled = false;
1516
+ let timer;
1517
+ let req;
1518
+ const limit = report?.item?.msg === 'alipay_aipay_skill_conversation' ? conversationUploadTimeoutMs : uploadTimeoutMs;
1519
+ const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, limit) : limit;
1459
1520
  const finish = (result) => {
1460
1521
  if (settled) return;
1461
1522
  settled = true;
1523
+ clearTimeout(timer);
1462
1524
  resolve(result);
1463
1525
  };
1526
+ const timeout = () => {
1527
+ finish({ ok: false, error_code: 'ETIMEDOUT', error_message: 'request timeout' });
1528
+ req?.destroy();
1529
+ };
1464
1530
  try {
1465
1531
  if (!report?.item || !report?.options) {
1466
1532
  finish({ ok: false, error_code: 'INVALID_REPORT', error_message: 'invalid report' });
1467
1533
  return;
1468
1534
  }
1469
1535
  const body = syncLogRequestBody(report);
1470
- const req = https.request(`${syncLogServerURL}?biztype=${syncLogBizType}`, {
1536
+ // A wall-clock deadline also covers DNS/TLS stalls and trickling responses.
1537
+ timer = setTimeout(timeout, budget);
1538
+ req = request(`${syncLogServerURL}?biztype=${syncLogBizType}`, {
1471
1539
  method: 'POST',
1472
1540
  headers: {
1473
1541
  'Content-Type': 'application/x-www-form-urlencoded',
1474
1542
  'Content-Length': Buffer.byteLength(body)
1475
1543
  },
1476
- timeout: timeoutMs
1544
+ timeout: budget
1477
1545
  }, (resp) => {
1478
1546
  resp.resume();
1547
+ resp.on('error', error => finish(failedResult(error)));
1479
1548
  resp.on('end', () => {
1480
1549
  const statusCode = Number(resp.statusCode || 0);
1481
1550
  const ok = isSuccessfulHttpStatus(statusCode);
@@ -1489,10 +1558,7 @@ export function sendSyncLogReportDetailed(report, {
1489
1558
  });
1490
1559
  });
1491
1560
  });
1492
- req.on('timeout', () => {
1493
- finish({ ok: false, error_code: 'ETIMEDOUT', error_message: 'request timeout' });
1494
- req.destroy();
1495
- });
1561
+ req.on('timeout', timeout);
1496
1562
  req.on('error', (error) => finish(failedResult(error)));
1497
1563
  req.end(body);
1498
1564
  } catch (error) {
@@ -1729,6 +1795,7 @@ export async function flushOutbox(env = process.env, {
1729
1795
  targetFile = '',
1730
1796
  timeoutMs = uploadTimeoutMs,
1731
1797
  sender = sendSyncLogReportDetailed,
1798
+ onSent = null,
1732
1799
  prioritizeTerminal = false,
1733
1800
  includeConversations = true
1734
1801
  } = {}) {
@@ -1745,8 +1812,22 @@ export async function flushOutbox(env = process.env, {
1745
1812
  }
1746
1813
  }
1747
1814
  for (const file of files) {
1748
- const claimedFile = claimOutboxFile(file);
1749
- if (!claimedFile) continue;
1815
+ const pending = file.endsWith('.conversation') ? readOutboxRecord(file) : null;
1816
+ const owner = pending && readConversationState(pending.event.conversation_id, env, { checkOwner: false });
1817
+ // Claim only after the recorder has committed state or removed its failed
1818
+ // enqueue. Release this local lock before doing any network work.
1819
+ const claim = owner ? withRunStateLock(env, () => {
1820
+ const state = reconcileConversationMessage(
1821
+ readConversationState(owner.conversation_id, env, { checkOwner: false }), env);
1822
+ return state ? claimOutboxFile(file) : '';
1823
+ }, conversationSessionFile(owner.session_hash, 'index.lock', env)) : null;
1824
+ const claimedFile = owner ? (claim.acquired ? claim.value : '') : claimOutboxFile(file);
1825
+ if (!claimedFile) {
1826
+ // A local recovery/claim failure is retained work, not an empty batch.
1827
+ // Let the worker stop instead of spinning on an uncommittable record.
1828
+ if (fs.existsSync(file)) result.retained += 1;
1829
+ continue;
1830
+ }
1750
1831
  const record = readOutboxRecord(claimedFile);
1751
1832
  if (!record) {
1752
1833
  result.invalid += 1;
@@ -1756,7 +1837,7 @@ export async function flushOutbox(env = process.env, {
1756
1837
  continue;
1757
1838
  }
1758
1839
  if (isConversationEvent(record.event)) {
1759
- // Finish stage accounting before a conversation request can wait indefinitely.
1840
+ // Finish stage accounting before handing off to conversation delivery.
1760
1841
  recordStageSuccess();
1761
1842
  // Legacy preview queues were captured before explicit consent existed.
1762
1843
  // Never retroactively authorize those messages with a later confirmation.
@@ -1766,6 +1847,13 @@ export async function flushOutbox(env = process.env, {
1766
1847
  result.invalid += 1;
1767
1848
  continue;
1768
1849
  }
1850
+ // A recorder queues before committing its sequence. An already-running
1851
+ // worker must leave that record pending until the state write succeeds.
1852
+ if (record.event.sequence > state.sequence) {
1853
+ releaseOutboxClaim(claimedFile);
1854
+ result.retained += 1;
1855
+ continue;
1856
+ }
1769
1857
  }
1770
1858
  const report = buildSyncLogReport(
1771
1859
  record.event,
@@ -1785,7 +1873,7 @@ export async function flushOutbox(env = process.env, {
1785
1873
  let sendResult = { ok: false, error_code: 'SENDER_FAILED', error_message: 'sender failed' };
1786
1874
  try {
1787
1875
  const rawSendResult = await sender(report, {
1788
- timeoutMs: isConversationEvent(record.event) ? 0 : timeoutMs
1876
+ timeoutMs: isConversationEvent(record.event) ? conversationUploadTimeoutMs : timeoutMs
1789
1877
  });
1790
1878
  sendResult = typeof rawSendResult === 'boolean' ? { ok: rawSendResult } : rawSendResult;
1791
1879
  } catch (error) {
@@ -1793,6 +1881,7 @@ export async function flushOutbox(env = process.env, {
1793
1881
  }
1794
1882
  if (sendResult?.ok) {
1795
1883
  result.sent += 1;
1884
+ if (typeof onSent === 'function') onSent();
1796
1885
  if (!conversation) stages.sent += 1;
1797
1886
  try {
1798
1887
  fs.unlinkSync(claimedFile);
@@ -1826,7 +1915,7 @@ function outboxFilesForFlush(env = process.env, {
1826
1915
  } = {}) {
1827
1916
  const files = outboxFiles(env, { targetFile, limit: 0 })
1828
1917
  .filter((file) => includeConversations || !file.endsWith('.conversation'))
1829
- // Drain bounded stage uploads before potentially unbounded conversation requests.
1918
+ // Drain stage uploads before the separately bounded conversation requests.
1830
1919
  .sort((a, b) => Number(a.endsWith('.conversation')) - Number(b.endsWith('.conversation')));
1831
1920
  if (!prioritizeTerminal || targetFile) return limit > 0 ? files.slice(0, limit) : files;
1832
1921
  const terminalFiles = [];
@@ -1840,11 +1929,139 @@ function outboxFilesForFlush(env = process.env, {
1840
1929
  return limit > 0 ? ordered.slice(0, limit) : ordered;
1841
1930
  }
1842
1931
 
1932
+ function uploadWorkerLeasePath(env) {
1933
+ return path.join(outboxDir(env), '.upload-worker.json');
1934
+ }
1935
+
1936
+ function readUploadWorkerLease(env) {
1937
+ try {
1938
+ const file = uploadWorkerLeasePath(env);
1939
+ const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
1940
+ if (!Number.isSafeInteger(lease.pid) || lease.pid <= 0 || !validConversationId(lease.token)) return null;
1941
+ // Older workers did not store a deadline. Bound their records by their
1942
+ // original file age instead of trusting a reused PID indefinitely.
1943
+ if (lease.started_at === undefined && lease.expires_at === undefined) {
1944
+ const startedAt = Math.floor(fs.statSync(file).mtimeMs);
1945
+ return { ...lease, started_at: startedAt,
1946
+ expires_at: startedAt + uploadWorkerLifetimeMs + 2 * runStateLockWaitMs };
1947
+ }
1948
+ return lease;
1949
+ } catch { return null; }
1950
+ }
1951
+
1952
+ function uploadWorkerProcessAlive(lease) {
1953
+ if (!lease) return false;
1954
+ try { process.kill(lease.pid, 0); return true; }
1955
+ catch (error) { return error.code !== 'ESRCH'; }
1956
+ }
1957
+
1958
+ function uploadWorkerAlive(lease) {
1959
+ if (!lease) return false;
1960
+ const now = Date.now();
1961
+ const maxAge = uploadWorkerLifetimeMs + 2 * runStateLockWaitMs;
1962
+ if (!Number.isSafeInteger(lease.started_at) || !Number.isSafeInteger(lease.expires_at)
1963
+ || lease.started_at <= 0 || lease.started_at > now || lease.expires_at <= now
1964
+ || lease.expires_at <= lease.started_at || lease.expires_at - lease.started_at > maxAge) return false;
1965
+ return uploadWorkerProcessAlive(lease);
1966
+ }
1967
+
1968
+ function releaseWorkerClaims(env, pid = process.pid) {
1969
+ if (!Number.isSafeInteger(pid) || pid <= 0) return;
1970
+ try {
1971
+ for (const name of fs.readdirSync(outboxDir(env))) {
1972
+ if (isOutboxClaimName(name) && name.endsWith(`.sending.${pid}`)) {
1973
+ releaseOutboxClaim(path.join(outboxDir(env), name));
1974
+ }
1975
+ }
1976
+ } catch {}
1977
+ }
1978
+
1979
+ async function runOutboxWorker(env) {
1980
+ // Startup and exit share this short metadata lock; no network work holds it.
1981
+ // Checking and replacing an expired/dead owner's lease are one operation.
1982
+ const testLifetime = Number(env.ALIPAY_AIPAY_TEST_WORKER_LIFETIME_MS);
1983
+ const lifetime = env.ALIPAY_AIPAY_TEST_MODE === '1' && Number.isFinite(testLifetime) && testLifetime > 0
1984
+ ? Math.min(testLifetime, uploadWorkerLifetimeMs) : uploadWorkerLifetimeMs;
1985
+ const transaction = withRunStateLock(env, () => {
1986
+ const previous = readUploadWorkerLease(env);
1987
+ if (uploadWorkerAlive(previous)) return null;
1988
+ // A still-live reused PID may own a different foreground upload. Leave its
1989
+ // claims to the existing TTL recovery instead of reclaiming active work.
1990
+ if (previous && !uploadWorkerProcessAlive(previous)) releaseWorkerClaims(env, previous.pid);
1991
+ const startedAt = Date.now();
1992
+ const lease = { pid: process.pid, token: crypto.randomUUID(), started_at: startedAt,
1993
+ expires_at: startedAt + lifetime + 2 * runStateLockWaitMs };
1994
+ return writePrivateJson(uploadWorkerLeasePath(env), lease) ? lease : null;
1995
+ }, `${uploadWorkerLeasePath(env)}.guard`);
1996
+ const lease = transaction.acquired && transaction.value;
1997
+ if (!lease) return;
1998
+ let released = false;
1999
+ let sent = 0;
2000
+ let drained = false;
2001
+ const cleanup = (keepIfPending = false) => {
2002
+ if (released) return false;
2003
+ const transaction = withRunStateLock(env, () => {
2004
+ if (readUploadWorkerLease(env)?.token !== lease.token) { released = true; return false; }
2005
+ // Foreground producers never wait for this guard. Check again after
2006
+ // releasing it, so messages queued in the empty-check / exit window drain.
2007
+ if (keepIfPending && outboxFilesForFlush(env, { limit: 1 }).length > 0) return true;
2008
+ releaseWorkerClaims(env);
2009
+ fs.unlinkSync(uploadWorkerLeasePath(env));
2010
+ released = true;
2011
+ return false;
2012
+ }, `${uploadWorkerLeasePath(env)}.guard`);
2013
+ return transaction.acquired && transaction.value;
2014
+ };
2015
+ // Production lifetime is fixed. Only the explicit test mode can shorten it.
2016
+ const watchdog = setTimeout(() => {
2017
+ cleanup();
2018
+ // Continue a healthy backlog in a new bounded worker. A stalled first
2019
+ // request never creates an endless chain of replacement processes.
2020
+ if (released && sent > 0 && hasPendingOutboxRecords(env)) spawnOutboxFlush({ ...env,
2021
+ ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE: '', ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS: '0' });
2022
+ process.exit(0);
2023
+ }, lifetime);
2024
+ try {
2025
+ const delay = parseNonNegativeInteger(env.ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS);
2026
+ if (delay) await new Promise(resolve => setTimeout(resolve, Math.min(delay, 30000)));
2027
+ const timeout = Number(env.ALIPAY_AIPAY_TELEMETRY_UPLOAD_TIMEOUT_MS || uploadTimeoutMs);
2028
+ let targetFile = env.ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE || '';
2029
+ while (!released) {
2030
+ const result = await flushOutbox(env, {
2031
+ targetFile,
2032
+ timeoutMs: Number.isFinite(timeout) ? timeout : uploadTimeoutMs,
2033
+ onSent: () => { sent += 1; }
2034
+ });
2035
+ targetFile = '';
2036
+ // Do not busy-retry network or local failures. Keep their records queued.
2037
+ if (result.retained > 0) break;
2038
+ if (!cleanup(true)) { drained = true; break; }
2039
+ }
2040
+ } finally {
2041
+ clearTimeout(watchdog);
2042
+ cleanup();
2043
+ if (drained && released && outboxFilesForFlush(env, { limit: 1 }).length > 0) {
2044
+ spawnOutboxFlush({ ...env,
2045
+ ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE: '', ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS: '0' });
2046
+ }
2047
+ }
2048
+ }
2049
+
1843
2050
  function spawnOutboxFlush(env = process.env, {
1844
2051
  delayMs = 0,
1845
2052
  recordStageFailure = hasPendingOutboxRecords(env, { includeConversations: false })
1846
2053
  } = {}) {
1847
2054
  if (!telemetryUploadEnabled(env)) return false;
2055
+ // Best effort: a busy uploader must not delay capture or business delivery.
2056
+ // Its post-release queue check handles an overlapping successful exit.
2057
+ const transaction = withRunStateLock(env, () => {
2058
+ if (uploadWorkerAlive(readUploadWorkerLease(env))) return true;
2059
+ return launchOutboxFlush(env, { delayMs, recordStageFailure });
2060
+ }, `${uploadWorkerLeasePath(env)}.guard`, 0);
2061
+ return transaction.acquired && transaction.value;
2062
+ }
2063
+
2064
+ function launchOutboxFlush(env, { delayMs, recordStageFailure }) {
1848
2065
  try {
1849
2066
  const childEnv = {
1850
2067
  ...env,
@@ -2066,13 +2283,7 @@ export async function runTelemetryCli(argv = process.argv) {
2066
2283
  if (command === 'flush-outbox-worker') {
2067
2284
  assertCliArgs(args, new Set(), 0);
2068
2285
  if (!telemetryUploadEnabled(process.env)) return 0;
2069
- const timeout = Number(process.env.ALIPAY_AIPAY_TELEMETRY_UPLOAD_TIMEOUT_MS || uploadTimeoutMs);
2070
- const delay = parseNonNegativeInteger(process.env.ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS);
2071
- if (delay && delay > 0) sleepSync(Math.min(delay, 30000));
2072
- await flushOutbox(process.env, {
2073
- targetFile: process.env.ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE || '',
2074
- timeoutMs: Number.isFinite(timeout) ? timeout : uploadTimeoutMs
2075
- });
2286
+ await runOutboxWorker(process.env);
2076
2287
  return 0;
2077
2288
  }
2078
2289
  if (!telemetryEnabled(process.env)) return 0;