@alipay/alipay-aipay 1.7.8 → 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.
Files changed (28) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -3
  3. package/skill/SKILL.md +34 -3
  4. package/skill/VERSION +1 -1
  5. package/skill/references/normal/conversation-telemetry.md +103 -0
  6. package/skill/references/normal/json/customer-messages.json +1 -1
  7. package/skill/references/normal/json/policy-rules.json +1 -1
  8. package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
  9. package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
  10. package/skill/references/normal/scripts/open_official_url.sh +4 -2
  11. package/skill/references/normal/scripts/render_customer_message.mjs +6 -2
  12. package/skill/references/normal/scripts/runtime.mjs +1 -0
  13. package/skill/references/normal/scripts/self_update.mjs +6 -7
  14. package/skill/references/normal/scripts/telemetry.mjs +618 -74
  15. package/skill/references/normal/scripts/windows/bootstrap-node.ps1 +0 -2
  16. package/skill/references/runtime-entry.md +7 -1
  17. package/skill-pre/VERSION +1 -1
  18. package/skill-pre/references/normal/conversation-telemetry.md +9 -9
  19. package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
  20. package/skill-pre/references/normal/scripts/runtime.mjs +4 -4
  21. package/skill-pre/references/normal/scripts/self_update.mjs +6 -7
  22. package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
  23. package/skill-pre/references/normal/scripts/windows/git-discovery.mjs +6 -2
  24. package/skill-pre/references/normal/scripts/windows/git-install.mjs +2 -3
  25. package/skill-pre/references/normal/scripts/windows/preflight.mjs +13 -2
  26. package/skill-pre/references/normal/scripts/windows/prepare.mjs +2 -2
  27. package/skill-pre/references/normal/scripts/windows/routing.mjs +4 -1
  28. package/skill-pre/references/normal/windows-environment.md +4 -4
@@ -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;
@@ -1,14 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {run, fail} from './host.mjs';
4
- const managedGitVersion=JSON.parse(fs.readFileSync(new URL('./policy.json',import.meta.url),'utf8')).git.version;
4
+ function managedGitVersion() {
5
+ try { return JSON.parse(fs.readFileSync(new URL('./policy.json',import.meta.url),'utf8')).git.version; }
6
+ catch { return ''; }
7
+ }
5
8
 
6
9
  export function gitCandidates(env = process.env) {
7
10
  if (env.ALIPAY_GIT_BASH) return [env.ALIPAY_GIT_BASH];
11
+ const managedVersion = env.LOCALAPPDATA ? managedGitVersion() : '';
8
12
  const candidates = [
9
13
  path.win32.join(env.ProgramFiles || 'C:/Program Files', 'Git/bin/bash.exe'),
10
14
  ...(env.LOCALAPPDATA ? [path.win32.join(env.LOCALAPPDATA, 'Programs/Git/bin/bash.exe'),
11
- path.win32.join(env.LOCALAPPDATA, `Programs/Git-Aipay-${managedGitVersion}/bin/bash.exe`)] : [])
15
+ ...(managedVersion ? [path.win32.join(env.LOCALAPPDATA, `Programs/Git-Aipay-${managedVersion}/bin/bash.exe`)] : [])] : [])
12
16
  ];
13
17
  const search = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] || '';
14
18
  for (const directory of search.split(';').filter(p => path.win32.isAbsolute(p))) {
@@ -1,14 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import {policy,run,fail} from './preflight.mjs';
4
+ import {loadPolicy,run,fail} from './preflight.mjs';
5
5
  import {findGitBash} from './git-discovery.mjs';
6
6
  import {powershell,psEnv} from './host.mjs';
7
7
 
8
8
  export function installGitOfficial({env=process.env,execute=run,exists=fs.existsSync,
9
9
  read=fs.readFileSync,remove=fs.rmSync,discover=findGitBash,root,
10
- signatureCommand=powershell,beforeAttempt=()=>{}}={}) {
11
- const asset=policy.git;
10
+ signatureCommand=powershell,beforeAttempt=()=>{},asset=loadPolicy('Git Bash').git}={}) {
12
11
  if(!root || !env.LOCALAPPDATA || !path.win32.isAbsolute(env.LOCALAPPDATA) ||
13
12
  !/^https:\/\/github\.com\/git-for-windows\/git\/releases\/download\/v[0-9.]+\.windows\.[0-9]+\/Git-[0-9.]+-64-bit\.exe$/u.test(asset.url) ||
14
13
  !/^[a-f0-9]{64}$/u.test(asset.sha256)) fail('GIT_ASSET_INVALID','Git Bash');
@@ -5,7 +5,18 @@ import crypto from 'node:crypto';
5
5
  import {fileURLToPath} from 'node:url';
6
6
 
7
7
  export const directory = path.dirname(fileURLToPath(import.meta.url));
8
- export const policy = JSON.parse(fs.readFileSync(path.join(directory,'policy.json'),'utf8'));
8
+ export function loadPolicy(dependency, read = fs.readFileSync) {
9
+ try {
10
+ const policy = JSON.parse(read(path.join(directory,'policy.json'),'utf8'));
11
+ const asset = dependency === 'Git Bash' ? policy?.git : policy?.jq;
12
+ if (!asset || typeof asset !== 'object' || typeof asset.url !== 'string' ||
13
+ typeof asset.sha256 !== 'string' || (dependency === 'Git Bash' && typeof asset.version !== 'string')) {
14
+ throw new Error('incomplete policy');
15
+ }
16
+ return policy;
17
+ }
18
+ catch { fail('POLICY_UNAVAILABLE',dependency); }
19
+ }
9
20
  const slash = p => p.replaceAll('\\','/');
10
21
  let invocationContext;
11
22
  import {fail, run, checkWritable} from './host.mjs';
@@ -69,7 +80,7 @@ export function locateJq() {
69
80
  }
70
81
  export function locate() {
71
82
  if(process.platform!=='win32') fail('WINDOWS_ADAPTER_REQUIRES_WINDOWS');
72
- if(Number(process.versions.node.split('.')[0])<policy.nodeMinimumMajor) fail('DEPENDENCY_VERSION','Node');
83
+ if(Number(process.versions.node.split('.')[0])<18) fail('DEPENDENCY_VERSION','Node');
73
84
  requireWindowsX64Node();
74
85
  const foundGit=findGitBash();
75
86
  if(!foundGit) fail('DEPENDENCY_MISSING','Git Bash');
@@ -7,7 +7,7 @@ import {findGitBash} from './git-discovery.mjs';
7
7
  import {findWinget} from './winget.mjs';
8
8
  import {installGitOfficial} from './git-install.mjs';
9
9
  import {powershell} from './host.mjs';
10
- import {policy,run,stateRoot,readState,saveState,withLock,ensureReady,locateJq,fail,report} from './preflight.mjs';
10
+ import {loadPolicy,run,stateRoot,readState,saveState,withLock,ensureReady,locateJq,fail,report} from './preflight.mjs';
11
11
 
12
12
  export function claimPreparation(state,stage,{source='official',repair=false,authorized=false}={}) {
13
13
  if(!authorized) fail('PREPARATION_AUTHORIZATION_REQUIRED');
@@ -65,7 +65,7 @@ export function prepareDependencies(state,{authorized=false,repair=false,save=()
65
65
  gitReady=()=>findGitBash(),jqReady=()=>locateJq(),
66
66
  cliReady=()=>{const cli=path.join(os.homedir(),'.local/bin/alipay-cli.exe');return fs.existsSync(cli)&&run(cli,['version']).status===0;},
67
67
  installWinget=installGitWithWinget,installOfficial=installGitOfficial,
68
- prepareJq=(beforeAttempt)=>{requireAssetDownloader();beforeAttempt();installAsset('jq',policy.jq);},
68
+ prepareJq=(beforeAttempt)=>{const asset=loadPolicy('jq').jq;requireAssetDownloader();beforeAttempt();installAsset('jq',asset);},
69
69
  prepareCli=(beforeAttempt)=>{powershell();beforeAttempt();if(refreshWindowsCli()!=='READY')fail('CLI_INSTALL_FAILED','alipay-cli');}
70
70
  }={}) {
71
71
  if(!authorized) fail('PREPARATION_AUTHORIZATION_REQUIRED');
@@ -6,12 +6,15 @@ const prefix = 'ALIPAY_AIPAY_INTERNAL:';
6
6
 
7
7
  // Optional tool failures do not prevent independent project code development.
8
8
  function codeOnlyFailure(error) {
9
+ if (error.reason === 'SHELL_TRANSPORT_FAILED' && error.dependency !== 'Node') return true;
10
+ if (error.reason === 'NOT_GIT_BASH' && error.dependency === 'ALIPAY_GIT_BASH') return true;
11
+ if (error.dependency === 'Node') return false;
9
12
  const tools = ['Git Bash', 'git', 'jq', 'alipay-cli', 'cli', 'curl', 'PowerShell'];
10
13
  if (!tools.includes(error.dependency)) return false;
11
14
  return ['DEPENDENCY_MISSING', 'DEPENDENCY_VERSION', 'EXECUTION_FAILED',
12
15
  'WINGET_UNAVAILABLE', 'GIT_INSTALL_FAILED', 'GIT_VERIFY_FAILED', 'CLI_INSTALL_FAILED',
13
16
  'DOWNLOAD_FAILED', 'INTEGRITY_FAILED', 'SIGNATURE_FAILED', 'RETRY_BUDGET_EXHAUSTED',
14
- 'USER_SCOPE_REQUIRED'].includes(error.reason);
17
+ 'USER_SCOPE_REQUIRED', 'POLICY_UNAVAILABLE'].includes(error.reason);
15
18
  }
16
19
 
17
20
  // This is a routing result, never proof of a successful payment or onboarding.
@@ -34,11 +34,11 @@ stdout 为空、原始安装器输出不对客,只映射根协议的唯一刷
34
34
 
35
35
  CLI 刷新策略与非 Windows 保持同一会话预算,不锁定 0.1.7 或其哈希;Windows 交由正式 PowerShell 安装器选择版本。jq 优先复用能通过版本和实际 filter 检查的既有原生工具;缺失或不可用时安装固定 1.7.1,官方 HTTPS 资产和 SHA-256 位于 `scripts/windows/policy.json`。Node 优先复用当前可运行的 Windows x64 Node >=18;缺失时由根启动器的 PowerShell 引导准备官方 LTS 用户级 ZIP,SHA-256 验证后返回绝对可执行路径,首次一次、明确修复最多一次。Git Bash 从常见目录和本机 PATH 的 Git 安装布局定位,也可由 `ALIPAY_GIT_BASH` 指定绝对路径;必须验证 Git for Windows 版本、cygpath 和 Bash 的 MINGW/MSYS 平台,不使用 System32/WSL Bash,显式指定错误时停止而不偷偷换工具。
36
36
 
37
- 初次缺 Git Bash、jq 或本机 CLI 无法启动且当前任务授权准备时,按 Git Bash → jq → 必要的 CLI 修复 → 完整复检执行。CLI 已可用时不重复安装。Git 优先复用已有可运行的 Git for Windows;缺失时优先调用用户级 winget,无法运行或未通过复检时使用 `scripts/windows/policy.json` 固定的 Git for Windows 官方 x64 安装包(当前 2.56.0.2)。官方包下载后先校验固定 SHA-256,再由系统 PowerShell 验证 Authenticode 有效,之后安装到 `%LOCALAPPDATA%/Programs/Git-Aipay-<version>`,并实测 git、bash、cygpath。下载或校验失败不运行安装包。两种来源均不可用时保持 BLOCKED,展示原因及官方手工安装路径,不静默跳站点。
37
+ 初次缺 Git Bash、jq 或本机 CLI 无法启动且当前任务授权准备时,按 Git Bash → jq → 必要的 CLI 修复 → 完整复检执行。CLI 已可用时不重复安装。Git 优先复用已有可运行的 Git for Windows;缺失时优先调用用户级 winget,无法运行或未通过复检时使用 `scripts/windows/policy.json` 固定的 Git for Windows 官方 x64 安装包(当前 2.56.0.2)。官方包下载后先校验固定 SHA-256,再由系统 PowerShell 验证 Authenticode 有效,之后安装到 `%LOCALAPPDATA%/Programs/Git-Aipay-<version>`,并实测 git、bash、cygpath。下载或校验失败不运行安装包。两种来源均不可用时按根意图返回 CODE_ONLY 或 BLOCKED,展示原因及官方手工安装路径,不静默跳站点。
38
38
 
39
- jq 使用固定 1.7.1 官方资产和 SHA-256,已有健康版本直接复用;CLI 仍使用正式 PowerShell 安装器选择当前版本。每个依赖和 Git 的两种安装来源分别记录实际开始的安装尝试,确认来源前的只读探测不消耗预算;Git 的 winget 失败可在同次准备中尝试官方固定包。初次每个来源最多一次,用户明确修复后每个来源最多再一次;失败阶段可续跑,已健康的阶段不重装。状态记录不含业务材料;复检始终只读且不消耗安装预算。网络、实际权限、签名或完整性失败保持 BLOCKED,不轮换未经验证的镜像、使用 WSL、提权或修改执行策略。
39
+ jq 使用固定 1.7.1 官方资产和 SHA-256,已有健康版本直接复用;CLI 仍使用正式 PowerShell 安装器选择当前版本。每个依赖和 Git 的两种安装来源分别记录实际开始的安装尝试,确认来源前的只读探测不消耗预算;Git 的 winget 失败可在同次准备中尝试官方固定包。初次每个来源最多一次,用户明确修复后每个来源最多再一次;失败阶段可续跑,已健康的阶段不重装。状态记录不含业务材料;复检始终只读且不消耗安装预算。可选工具的网络、签名或完整性失败按第 21 行分流;实际存储故障仍为 BLOCKED。不轮换未经验证的镜像、使用 WSL、提权或修改执行策略。
40
40
 
41
- 环境诊断和次数保存在 `%LOCALAPPDATA%/alipay-aipay-pre/windows/environment.json`;旧版全局次数无法归属具体安装阶段,迁移时保留阻断原因并从新版本的有限分阶段预算重新计数。锁短时占用或状态损坏时,完整工具检查通过仍可 READY;自动安装保持阻断,不删除锁或状态绕过预算。用户修好依赖后,只读复检可再次执行,无论此前安装预算是否用尽:
41
+ 环境诊断和次数保存在 `%LOCALAPPDATA%/alipay-aipay-pre/windows/environment.json`;旧版全局次数无法归属具体安装阶段,迁移时保留阻断原因并从新版本的有限分阶段预算重新计数。锁短时占用或状态损坏时,完整工具检查通过仍可 READY;自动安装保持阻断,不删除锁或状态绕过预算。CODE_ONLY 的恢复使用第 23 行 `windows-entry`;以下仅适用于 BLOCKED。用户修好依赖后,只读复检可再次执行,无论此前安装预算是否用尽:
42
42
 
43
43
  ```text
44
44
  node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" env retry --repair-confirmed true --root-intent "<ROOT_INTENT>" --agent-platform "<AGENT_PLATFORM>" --session-id "<SESSION_ID>" --windows-route BLOCKED
@@ -52,7 +52,7 @@ node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" env prepare --authorize
52
52
 
53
53
  如果之前只是缺少授权或没有找到安装来源,尚未实际尝试该阶段,则使用同一命令但省略 `--repair-confirmed true`,继续首次准备;不能把新授权当成已消耗预算后的额外重试许可。
54
54
 
55
- 仅退出 0、唯一 `CLI_ENV:READY` 和唯一根意图匹配的 `WINDOWS_ROUTE=READY` 可更新分流。其他结果保持 BLOCKED,说明具体失败原因;不重建会话、重复计数或重放业务操作。用户明确选择站点或平台执行映射不支持时,可走原 LEGACY 分支;可恢复的依赖失败不能自动转站点。
55
+ 仅退出 0、唯一 `CLI_ENV:READY` 和唯一根意图匹配的 `WINDOWS_ROUTE=READY` 可恢复沙箱及开通能力。BLOCKED 恢复命令的其他结果保持 BLOCKED,说明具体失败原因;不重建会话、重复计数或重放业务操作。CODE_ONLY 按第 23 行重新取得入口分流。用户明确选择站点或平台执行映射不支持时,可走原 LEGACY 分支;可恢复的依赖失败不能自动转站点。
56
56
 
57
57
  ## 路径、权限与进程
58
58