@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.
- package/package.json +3 -3
- package/skill/SKILL.md +34 -3
- package/skill/VERSION +1 -1
- package/skill/references/normal/conversation-telemetry.md +103 -0
- package/skill/references/normal/json/customer-messages.json +1 -1
- package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
- package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
- package/skill/references/normal/scripts/render_customer_message.mjs +2 -0
- package/skill/references/normal/scripts/runtime.mjs +1 -0
- package/skill/references/normal/scripts/telemetry.mjs +618 -74
- package/skill/references/runtime-entry.md +7 -1
- package/skill-pre/VERSION +1 -1
- package/skill-pre/references/normal/conversation-telemetry.md +9 -9
- package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
- package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
|
@@ -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 (
|
|
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)
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' ?
|
|
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
|
-
|
|
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:
|
|
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
|
|
1749
|
-
|
|
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
|
|
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) ?
|
|
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
|
|
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
|
-
|
|
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;
|