@qwen-code/channel-dingtalk 0.22.3 → 0.23.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.
@@ -15,6 +15,7 @@ export declare class StatusCardController {
15
15
  private readonly recordsByOutTrack;
16
16
  private readonly segmentIdsByRun;
17
17
  private readonly terminalSegmentIds;
18
+ private disposed;
18
19
  constructor(options: StatusCardControllerOptions);
19
20
  ensure(segment: ChannelOutputSegmentContext, target: {
20
21
  chatId: string;
@@ -28,7 +29,9 @@ export declare class StatusCardController {
28
29
  * Whether a created, still-running status card is displaying content for
29
30
  * this segment. Awaits the in-flight creation so a boundary decision made
30
31
  * while creation is pending does not race it. A latched stream failure
31
- * means the card can never show further content, so it is not live.
32
+ * means the card can never show further content, so it is not live. A
33
+ * creation that is backing off for a retry is not awaited either (see
34
+ * `awaitDelivery`).
32
35
  */
33
36
  isCardLive(segmentId: string): Promise<boolean>;
34
37
  /**
@@ -38,15 +41,35 @@ export declare class StatusCardController {
38
41
  * caller can fall back instead of claiming delivery.
39
42
  */
40
43
  flushPending(segmentId: string): Promise<boolean>;
44
+ abandon(segmentId: string): void;
45
+ /**
46
+ * Resolves once the card is known to be delivered (or not) without waiting
47
+ * through a creation backoff: `creationAttempt` is the failed attempt until
48
+ * the retry actually starts, so a boundary reached mid-backoff falls back
49
+ * to text while the retry keeps running for later output.
50
+ */
51
+ private awaitDelivery;
41
52
  private createRecord;
42
53
  complete(segmentId: string, text: string, retainedContent?: (content: string) => string): Promise<boolean>;
43
54
  fail(segmentId: string, error: string): void;
44
55
  cancelRun(runId: string, reason: ChannelTaskCancellationReason): void;
45
56
  claimStop(outTrackId: string, actorId: string): DingtalkCardCallbackResult;
46
57
  private create;
58
+ /**
59
+ * Sleeps for the next creation backoff. Resolves false when finalization or
60
+ * disposal abandons the creation so `ready` settles without waiting.
61
+ */
62
+ private waitForCreationRetry;
47
63
  private scheduleFlush;
48
64
  private flush;
49
65
  private finalize;
66
+ private attemptFinalization;
67
+ private scheduleStreamRetry;
68
+ private scheduleTerminalRetry;
69
+ private handleStreamFailure;
70
+ private retryDelay;
71
+ private removeRecord;
72
+ dispose(): void;
50
73
  private statusLine;
51
74
  private updateRunningStatus;
52
75
  private scheduleStatusRefresh;
@@ -1 +1 @@
1
- {"version":3,"file":"status-card-controller.d.ts","sourceRoot":"","sources":["../src/status-card-controller.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,2BAA2B,EAC3B,6BAA6B,EAC9B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AAO9E,eAAO,MAAM,aAAa,QAAS,CAAC;AACpC,eAAO,MAAM,iBAAiB,iCAAiC,CAAC;AA4BhE,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,6BAA6B,CAAC;IACtC,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CACnD;AASD,qBAAa,oBAAoB;IAMnB,OAAO,CAAC,QAAQ,CAAC,OAAO;IALpC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAmC;IACpE,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAmC;IACrE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkC;IAClE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;gBAE3B,OAAO,EAAE,2BAA2B;IAEjE,MAAM,CACJ,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GAC3C,IAAI;IAOP,OAAO,CACL,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,EAC5C,OAAO,EAAE,MAAM,GACd,IAAI;IAcP;;;;;OAKG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAMrD;;;;;OAKG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgBvD,OAAO,CAAC,YAAY;IAsCpB,QAAQ,CACN,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAC5C,OAAO,CAAC,OAAO,CAAC;IAUnB,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAI5C,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,6BAA6B,GAAG,IAAI;IAarE,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,0BAA0B;YAmC5D,MAAM;IA0CpB,OAAO,CAAC,aAAa;IAYrB,OAAO,CAAC,KAAK;YAyCC,QAAQ;IA0EtB,OAAO,CAAC,UAAU;YAeJ,mBAAmB;IAwBjC,OAAO,CAAC,qBAAqB;CA2B9B"}
1
+ {"version":3,"file":"status-card-controller.d.ts","sourceRoot":"","sources":["../src/status-card-controller.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,2BAA2B,EAC3B,6BAA6B,EAC9B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAGL,KAAK,6BAA6B,EACnC,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AAU9E,eAAO,MAAM,aAAa,QAAS,CAAC;AACpC,eAAO,MAAM,iBAAiB,iCAAiC,CAAC;AAkDhE,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,6BAA6B,CAAC;IACtC,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CACnD;AASD,qBAAa,oBAAoB;IAOnB,OAAO,CAAC,QAAQ,CAAC,OAAO;IANpC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAmC;IACpE,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAmC;IACrE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkC;IAClE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,QAAQ,CAAS;gBAEI,OAAO,EAAE,2BAA2B;IAEjE,MAAM,CACJ,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GAC3C,IAAI;IAQP,OAAO,CACL,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,EAC5C,OAAO,EAAE,MAAM,GACd,IAAI;IAgBP;;;;;;;OAOG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAOrD;;;;;OAKG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAuBvD,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAqBhC;;;;;OAKG;YACW,aAAa;IAK3B,OAAO,CAAC,YAAY;IA8CpB,QAAQ,CACN,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAC5C,OAAO,CAAC,OAAO,CAAC;IAUnB,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAI5C,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,6BAA6B,GAAG,IAAI;IAcrE,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,0BAA0B;YAoC5D,MAAM;IAqDpB;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAc5B,OAAO,CAAC,aAAa;IAkBrB,OAAO,CAAC,KAAK;YA4CC,QAAQ;YA0CR,mBAAmB;IA0DjC,OAAO,CAAC,mBAAmB;IAiB3B,OAAO,CAAC,qBAAqB;IAY7B,OAAO,CAAC,mBAAmB;IAoB3B,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,YAAY;IAuBpB,OAAO,IAAI,IAAI;IAWf,OAAO,CAAC,UAAU;YAeJ,mBAAmB;IAiCjC,OAAO,CAAC,qBAAqB;CAiC9B"}
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { STATUS_CARD_TEMPLATE_ID, } from './interactive-card-client.js';
2
+ import { STATUS_CARD_TEMPLATE_ID, isRetryableDingtalkCardError, } from './interactive-card-client.js';
3
3
  import { sanitizeStreamingImageMarkers } from './outbound-image.js';
4
4
  const FLUSH_INTERVAL_MS = 500;
5
5
  const STATUS_REFRESH_INTERVAL_MS = 1_000;
6
+ const CONTENT_SYNC_INTERVAL_SECONDS = 5;
6
7
  const BREAKER_PROBE_INTERVAL_MS = 30_000;
7
8
  const MAX_CONSECUTIVE_STATUS_FAILURES = 3;
9
+ const INITIAL_RETRY_INTERVAL_MS = 1_000;
10
+ const MAX_RETRY_INTERVAL_MS = 30_000;
8
11
  export const CONTENT_LIMIT = 20_000;
9
12
  export const TRUNCATION_MARKER = '[Earlier output truncated]\n';
10
13
  function boundContent(content) {
@@ -18,10 +21,13 @@ export class StatusCardController {
18
21
  recordsByOutTrack = new Map();
19
22
  segmentIdsByRun = new Map();
20
23
  terminalSegmentIds = new Set();
24
+ disposed = false;
21
25
  constructor(options) {
22
26
  this.options = options;
23
27
  }
24
28
  ensure(segment, target) {
29
+ if (this.disposed)
30
+ return;
25
31
  if (this.terminalSegmentIds.has(segment.segmentId))
26
32
  return;
27
33
  if (!this.recordsBySegment.has(segment.segmentId)) {
@@ -29,6 +35,8 @@ export class StatusCardController {
29
35
  }
30
36
  }
31
37
  replace(segment, target, content) {
38
+ if (this.disposed)
39
+ return;
32
40
  if (this.terminalSegmentIds.has(segment.segmentId))
33
41
  return;
34
42
  const record = this.recordsBySegment.get(segment.segmentId);
@@ -39,22 +47,27 @@ export class StatusCardController {
39
47
  if (record.terminal)
40
48
  return;
41
49
  record.content = boundContent(content);
50
+ record.contentVersion++;
42
51
  if (record.streamFailed)
43
52
  return;
44
- record.pendingSnapshot = sanitizeStreamingImageMarkers(record.content);
53
+ record.hasPendingWrite = true;
45
54
  this.scheduleFlush(record);
46
55
  }
47
56
  /**
48
57
  * Whether a created, still-running status card is displaying content for
49
58
  * this segment. Awaits the in-flight creation so a boundary decision made
50
59
  * while creation is pending does not race it. A latched stream failure
51
- * means the card can never show further content, so it is not live.
60
+ * means the card can never show further content, so it is not live. A
61
+ * creation that is backing off for a retry is not awaited either (see
62
+ * `awaitDelivery`).
52
63
  */
53
64
  async isCardLive(segmentId) {
65
+ if (this.disposed)
66
+ return false;
54
67
  const record = this.recordsBySegment.get(segmentId);
55
68
  if (!record || record.terminal || record.streamFailed)
56
69
  return false;
57
- return record.ready;
70
+ return this.awaitDelivery(record);
58
71
  }
59
72
  /**
60
73
  * Drain any pending snapshot so callers can treat the card's current
@@ -63,23 +76,65 @@ export class StatusCardController {
63
76
  * caller can fall back instead of claiming delivery.
64
77
  */
65
78
  async flushPending(segmentId) {
79
+ if (this.disposed)
80
+ return false;
66
81
  const record = this.recordsBySegment.get(segmentId);
67
82
  if (!record || record.terminal)
68
83
  return false;
69
- if (!(await record.ready))
84
+ if (!(await this.awaitDelivery(record)))
70
85
  return false;
71
86
  while (!record.terminal && !record.streamFailed) {
72
87
  if (record.flushTimer) {
73
88
  clearTimeout(record.flushTimer);
74
89
  record.flushTimer = undefined;
75
90
  }
91
+ if (record.streamRetryTimer) {
92
+ clearTimeout(record.streamRetryTimer);
93
+ record.streamRetryTimer = undefined;
94
+ }
95
+ const failureVersion = record.streamFailureVersion;
76
96
  this.flush(record);
77
97
  await record.writeChain;
78
- if (record.pendingSnapshot === undefined)
98
+ if (record.streamFailureVersion !== failureVersion)
99
+ return false;
100
+ if (!record.hasPendingWrite)
79
101
  break;
80
102
  }
81
103
  return !record.terminal && !record.streamFailed;
82
104
  }
105
+ abandon(segmentId) {
106
+ const record = this.recordsBySegment.get(segmentId);
107
+ if (!record || record.terminal)
108
+ return;
109
+ record.terminal = true;
110
+ record.hasPendingWrite = false;
111
+ void record.ready.then((ready) => {
112
+ if (!ready)
113
+ return;
114
+ void this.options.client
115
+ .updateInstance({
116
+ outTrackId: record.outTrackId,
117
+ cardParamMap: {
118
+ flowStatus: 3,
119
+ hasAction: 'false',
120
+ stop_action: 'false',
121
+ },
122
+ })
123
+ .catch(() => { });
124
+ });
125
+ this.removeRecord(record);
126
+ }
127
+ /**
128
+ * Resolves once the card is known to be delivered (or not) without waiting
129
+ * through a creation backoff: `creationAttempt` is the failed attempt until
130
+ * the retry actually starts, so a boundary reached mid-backoff falls back
131
+ * to text while the retry keeps running for later output.
132
+ */
133
+ async awaitDelivery(record) {
134
+ if (!(await record.creationAttempt))
135
+ return false;
136
+ return record.ready;
137
+ }
83
138
  createRecord(segment, target, initialContent = '') {
84
139
  const outTrackId = `qwen-status-${randomUUID()}`;
85
140
  const record = {
@@ -92,13 +147,21 @@ export class StatusCardController {
92
147
  content: boundContent(initialContent),
93
148
  startedAt: Date.now(),
94
149
  lastStatusSecond: 0,
150
+ lastContentSyncSecond: 0,
95
151
  ready: Promise.resolve(false),
152
+ creationAttempt: Promise.resolve(false),
96
153
  terminal: false,
97
154
  streamFailed: false,
155
+ streamFailureVersion: 0,
156
+ createRetryAttempt: 0,
157
+ streamRetryAttempt: 0,
158
+ terminalRetryAttempt: 0,
98
159
  consecutiveStatusFailures: 0,
99
160
  stopClaimed: false,
100
161
  forbiddenActors: new Set(),
101
162
  lastWriteAt: Date.now(),
163
+ contentVersion: 0,
164
+ hasPendingWrite: false,
102
165
  writeChain: Promise.resolve(),
103
166
  };
104
167
  this.recordsBySegment.set(record.segmentId, record);
@@ -120,6 +183,8 @@ export class StatusCardController {
120
183
  void this.finalize(segmentId, boundContent(error), 'Failed', true);
121
184
  }
122
185
  cancelRun(runId, reason) {
186
+ if (this.disposed)
187
+ return;
123
188
  for (const segmentId of [...(this.segmentIdsByRun.get(runId) ?? [])]) {
124
189
  const record = this.recordsBySegment.get(segmentId);
125
190
  if (!record)
@@ -128,6 +193,8 @@ export class StatusCardController {
128
193
  }
129
194
  }
130
195
  claimStop(outTrackId, actorId) {
196
+ if (this.disposed)
197
+ return { kind: 'ignored', actorId };
131
198
  const record = this.recordsByOutTrack.get(outTrackId);
132
199
  if (!record || record.terminal || record.stopClaimed) {
133
200
  return { kind: 'ignored', actorId };
@@ -157,46 +224,79 @@ export class StatusCardController {
157
224
  };
158
225
  }
159
226
  async create(record, target) {
160
- try {
161
- const initialContent = sanitizeStreamingImageMarkers(record.content);
162
- await this.options.client.createAndDeliver({
163
- templateId: STATUS_CARD_TEMPLATE_ID,
164
- outTrackId: record.outTrackId,
165
- target,
166
- cardParamMap: {
167
- content: initialContent,
168
- flowStatus: 2,
169
- statusLine: this.statusLine(record, 'Running').text,
170
- hasAction: 'true',
171
- stop_action: 'true',
172
- },
227
+ for (;;) {
228
+ let settleAttempt;
229
+ record.creationAttempt = new Promise((resolve) => {
230
+ settleAttempt = resolve;
173
231
  });
232
+ try {
233
+ await this.options.client.createAndDeliver({
234
+ templateId: STATUS_CARD_TEMPLATE_ID,
235
+ outTrackId: record.outTrackId,
236
+ target,
237
+ cardParamMap: {
238
+ content: sanitizeStreamingImageMarkers(record.content),
239
+ flowStatus: 2,
240
+ statusLine: this.statusLine(record, 'Running').text,
241
+ hasAction: 'true',
242
+ stop_action: 'true',
243
+ },
244
+ });
245
+ settleAttempt(true);
246
+ break;
247
+ }
248
+ catch (error) {
249
+ settleAttempt(false);
250
+ this.options.onError?.('status card creation', error);
251
+ if (this.disposed ||
252
+ record.terminal ||
253
+ !isRetryableDingtalkCardError(error)) {
254
+ return false;
255
+ }
256
+ if (!(await this.waitForCreationRetry(record)))
257
+ return false;
258
+ }
259
+ }
260
+ if (this.disposed)
261
+ return false;
262
+ if (this.recordsBySegment.get(record.segmentId) !== record)
263
+ return true;
264
+ try {
174
265
  await this.options.client.openOrUpdateStream({
175
266
  outTrackId: record.outTrackId,
176
267
  key: 'content',
177
- content: initialContent,
268
+ content: sanitizeStreamingImageMarkers(record.content),
178
269
  finalize: false,
179
270
  });
180
- return true;
181
271
  }
182
272
  catch (error) {
183
- this.options.onError?.('status card creation', error);
184
- await this.options.client
185
- .updateInstance({
186
- outTrackId: record.outTrackId,
187
- cardParamMap: {
188
- flowStatus: 3,
189
- statusLine: 'Unavailable',
190
- hasAction: 'false',
191
- stop_action: 'false',
192
- },
193
- })
194
- .catch(() => { });
195
- return false;
273
+ this.handleStreamFailure(record, error);
196
274
  }
275
+ return true;
276
+ }
277
+ /**
278
+ * Sleeps for the next creation backoff. Resolves false when finalization or
279
+ * disposal abandons the creation so `ready` settles without waiting.
280
+ */
281
+ waitForCreationRetry(record) {
282
+ const delay = this.retryDelay(record.createRetryAttempt++);
283
+ return new Promise((resolve) => {
284
+ const settle = (resumed) => {
285
+ if (record.createRetryTimer)
286
+ clearTimeout(record.createRetryTimer);
287
+ record.createRetryTimer = undefined;
288
+ record.abandonCreation = undefined;
289
+ resolve(resumed);
290
+ };
291
+ record.abandonCreation = () => settle(false);
292
+ record.createRetryTimer = setTimeout(() => settle(true), delay);
293
+ });
197
294
  }
198
295
  scheduleFlush(record) {
199
- if (record.flushTimer || record.inFlight || record.terminal)
296
+ if (record.flushTimer ||
297
+ record.streamRetryTimer ||
298
+ record.inFlight ||
299
+ record.terminal)
200
300
  return;
201
301
  const delay = Math.max(0, FLUSH_INTERVAL_MS - (Date.now() - record.lastWriteAt));
202
302
  record.flushTimer = setTimeout(() => {
@@ -205,46 +305,50 @@ export class StatusCardController {
205
305
  }, delay);
206
306
  }
207
307
  flush(record) {
208
- if (record.terminal ||
308
+ if (this.disposed ||
309
+ record.terminal ||
310
+ record.streamFailed ||
209
311
  record.inFlight ||
210
- record.pendingSnapshot === undefined)
312
+ !record.hasPendingWrite)
211
313
  return;
212
- const content = record.pendingSnapshot;
213
- record.pendingSnapshot = undefined;
314
+ record.hasPendingWrite = false;
315
+ let sentVersion;
316
+ let contentWritten = false;
214
317
  const write = record.writeChain
215
318
  .then(async () => {
216
319
  const ready = await record.ready;
217
- if (!ready || record.terminal)
320
+ if (!ready || record.terminal || record.streamFailed)
218
321
  return;
322
+ sentVersion = record.contentVersion;
219
323
  await this.options.client.openOrUpdateStream({
220
324
  outTrackId: record.outTrackId,
221
325
  key: 'content',
222
- content,
326
+ content: sanitizeStreamingImageMarkers(record.content),
223
327
  finalize: false,
224
328
  });
329
+ contentWritten = true;
330
+ record.streamRetryAttempt = 0;
225
331
  await this.updateRunningStatus(record);
226
332
  })
227
- .catch((error) => {
228
- record.streamFailed = true;
229
- record.pendingSnapshot = undefined;
230
- if (record.statusTimer) {
231
- clearTimeout(record.statusTimer);
232
- record.statusTimer = undefined;
233
- }
234
- this.options.onError?.('status card streaming', error);
235
- });
333
+ .catch((error) => this.handleStreamFailure(record, error));
236
334
  const tracked = write.finally(() => {
237
335
  if (record.inFlight === tracked) {
238
336
  record.inFlight = undefined;
239
337
  }
240
338
  record.lastWriteAt = Date.now();
241
- if (record.pendingSnapshot !== undefined)
339
+ if (contentWritten && record.contentVersion === sentVersion) {
340
+ record.hasPendingWrite = false;
341
+ }
342
+ if (record.hasPendingWrite && record.contentVersion !== sentVersion) {
242
343
  this.scheduleFlush(record);
344
+ }
243
345
  });
244
346
  record.inFlight = tracked;
245
347
  record.writeChain = tracked;
246
348
  }
247
349
  async finalize(segmentId, content, state, isError, retainedContent) {
350
+ if (this.disposed)
351
+ return false;
248
352
  const record = this.recordsBySegment.get(segmentId);
249
353
  if (!record || record.terminal)
250
354
  return false;
@@ -259,61 +363,175 @@ export class StatusCardController {
259
363
  if (record.flushTimer)
260
364
  clearTimeout(record.flushTimer);
261
365
  record.flushTimer = undefined;
366
+ if (record.streamRetryTimer)
367
+ clearTimeout(record.streamRetryTimer);
368
+ record.streamRetryTimer = undefined;
262
369
  if (record.statusTimer)
263
370
  clearTimeout(record.statusTimer);
264
371
  record.statusTimer = undefined;
265
- record.pendingSnapshot = undefined;
372
+ record.hasPendingWrite = false;
373
+ record.abandonCreation?.();
374
+ if (!(await record.ready)) {
375
+ this.removeRecord(record);
376
+ return false;
377
+ }
378
+ await record.writeChain;
379
+ const retained = content ||
380
+ (retainedContent ? retainedContent(record.content) : record.content);
381
+ record.terminalIntent = {
382
+ content: boundContent(sanitizeStreamingImageMarkers(retained)),
383
+ isError,
384
+ statusLine: this.statusLine(record, state).text,
385
+ streamFinalizeSettled: false,
386
+ };
387
+ return this.attemptFinalization(record);
388
+ }
389
+ async attemptFinalization(record) {
390
+ if (this.disposed)
391
+ return false;
392
+ if (this.recordsBySegment.get(record.segmentId) !== record)
393
+ return false;
394
+ const intent = record.terminalIntent;
395
+ if (!intent)
396
+ return false;
397
+ if (!intent.streamFinalizeSettled) {
398
+ try {
399
+ await this.options.client.openOrUpdateStream({
400
+ outTrackId: record.outTrackId,
401
+ key: 'content',
402
+ content: '',
403
+ finalize: true,
404
+ isError: intent.isError,
405
+ });
406
+ intent.streamFinalizeSettled = true;
407
+ }
408
+ catch (error) {
409
+ if (!isRetryableDingtalkCardError(error)) {
410
+ intent.streamFinalizeSettled = true;
411
+ }
412
+ this.options.onError?.('status card finalization', error);
413
+ }
414
+ }
415
+ if (this.disposed)
416
+ return false;
266
417
  try {
267
- if (!(await record.ready))
268
- return false;
269
- await record.writeChain;
270
- const retained = content ||
271
- (retainedContent ? retainedContent(record.content) : record.content);
272
- const finalContent = boundContent(sanitizeStreamingImageMarkers(retained));
273
- await this.options.client.openOrUpdateStream({
274
- outTrackId: record.outTrackId,
275
- key: 'content',
276
- content: '',
277
- finalize: true,
278
- isError,
279
- });
280
418
  await this.options.client.updateInstance({
281
419
  outTrackId: record.outTrackId,
282
420
  cardParamMap: {
283
421
  blockList: JSON.stringify([
284
422
  {
285
423
  type: 0,
286
- markdown: finalContent,
424
+ markdown: intent.content,
287
425
  },
288
426
  ]),
289
- content: finalContent,
290
- copy_content: finalContent,
427
+ content: intent.content,
428
+ copy_content: intent.content,
291
429
  flowStatus: 3,
292
- statusLine: this.statusLine(record, state).text,
430
+ statusLine: intent.statusLine,
293
431
  hasAction: 'false',
294
432
  stop_action: 'false',
295
433
  },
296
434
  });
297
435
  record.content = '';
436
+ this.removeRecord(record);
298
437
  return true;
299
438
  }
300
439
  catch (error) {
301
440
  this.options.onError?.('status card finalization', error);
302
- return false;
303
- }
304
- finally {
305
- if (this.recordsBySegment.get(segmentId) === record) {
306
- this.recordsBySegment.delete(segmentId);
441
+ if (isRetryableDingtalkCardError(error)) {
442
+ this.scheduleTerminalRetry(record);
443
+ return true;
307
444
  }
308
- if (this.recordsByOutTrack.get(record.outTrackId) === record) {
309
- this.recordsByOutTrack.delete(record.outTrackId);
445
+ else {
446
+ this.removeRecord(record);
310
447
  }
311
- const segmentIds = this.segmentIdsByRun.get(record.runId);
312
- segmentIds?.delete(segmentId);
313
- if (segmentIds?.size === 0) {
314
- this.segmentIdsByRun.delete(record.runId);
448
+ return false;
449
+ }
450
+ }
451
+ scheduleStreamRetry(record) {
452
+ if (this.disposed ||
453
+ record.terminal ||
454
+ record.streamFailed ||
455
+ record.streamRetryTimer) {
456
+ return;
457
+ }
458
+ const delay = this.retryDelay(record.streamRetryAttempt++);
459
+ record.streamRetryTimer = setTimeout(() => {
460
+ record.streamRetryTimer = undefined;
461
+ if (this.disposed || record.terminal || record.streamFailed)
462
+ return;
463
+ this.flush(record);
464
+ }, delay);
465
+ }
466
+ scheduleTerminalRetry(record) {
467
+ if (this.disposed || !record.terminalIntent || record.terminalRetryTimer) {
468
+ return;
469
+ }
470
+ const delay = this.retryDelay(record.terminalRetryAttempt++);
471
+ record.terminalRetryTimer = setTimeout(() => {
472
+ record.terminalRetryTimer = undefined;
473
+ if (this.disposed)
474
+ return;
475
+ void this.attemptFinalization(record);
476
+ }, delay);
477
+ }
478
+ handleStreamFailure(record, error) {
479
+ record.streamFailureVersion++;
480
+ if (!this.disposed &&
481
+ !record.terminal &&
482
+ isRetryableDingtalkCardError(error)) {
483
+ record.hasPendingWrite = true;
484
+ this.scheduleStreamRetry(record);
485
+ }
486
+ else if (!record.terminal) {
487
+ record.streamFailed = true;
488
+ record.hasPendingWrite = false;
489
+ if (record.statusTimer) {
490
+ clearTimeout(record.statusTimer);
491
+ record.statusTimer = undefined;
315
492
  }
316
493
  }
494
+ this.options.onError?.('status card streaming', error);
495
+ }
496
+ retryDelay(attempt) {
497
+ return Math.min(INITIAL_RETRY_INTERVAL_MS * 2 ** Math.min(attempt, 10), MAX_RETRY_INTERVAL_MS);
498
+ }
499
+ removeRecord(record) {
500
+ record.abandonCreation?.();
501
+ if (record.flushTimer)
502
+ clearTimeout(record.flushTimer);
503
+ if (record.streamRetryTimer)
504
+ clearTimeout(record.streamRetryTimer);
505
+ if (record.terminalRetryTimer)
506
+ clearTimeout(record.terminalRetryTimer);
507
+ if (record.statusTimer)
508
+ clearTimeout(record.statusTimer);
509
+ record.flushTimer = undefined;
510
+ record.streamRetryTimer = undefined;
511
+ record.terminalRetryTimer = undefined;
512
+ record.statusTimer = undefined;
513
+ if (this.recordsBySegment.get(record.segmentId) === record) {
514
+ this.recordsBySegment.delete(record.segmentId);
515
+ }
516
+ if (this.recordsByOutTrack.get(record.outTrackId) === record) {
517
+ this.recordsByOutTrack.delete(record.outTrackId);
518
+ }
519
+ const segmentIds = this.segmentIdsByRun.get(record.runId);
520
+ segmentIds?.delete(record.segmentId);
521
+ if (segmentIds?.size === 0) {
522
+ this.segmentIdsByRun.delete(record.runId);
523
+ }
524
+ }
525
+ dispose() {
526
+ if (this.disposed)
527
+ return;
528
+ this.disposed = true;
529
+ for (const record of [...this.recordsBySegment.values()]) {
530
+ record.terminal = true;
531
+ record.hasPendingWrite = false;
532
+ this.removeRecord(record);
533
+ }
534
+ this.terminalSegmentIds.clear();
317
535
  }
318
536
  statusLine(record, state) {
319
537
  const second = Math.max(0, Math.floor((Date.now() - record.startedAt) / 1000));
@@ -324,17 +542,26 @@ export class StatusCardController {
324
542
  };
325
543
  }
326
544
  async updateRunningStatus(record) {
327
- if (record.terminal || record.streamFailed)
545
+ if (this.disposed || record.terminal || record.streamFailed)
328
546
  return;
329
547
  const status = this.statusLine(record, 'Running');
330
548
  if (status.second === record.lastStatusSecond)
331
549
  return;
550
+ const syncContent = status.second - record.lastContentSyncSecond >=
551
+ CONTENT_SYNC_INTERVAL_SECONDS;
332
552
  try {
333
553
  await this.options.client.updateInstance({
334
554
  outTrackId: record.outTrackId,
335
- cardParamMap: { statusLine: status.text },
555
+ cardParamMap: {
556
+ ...(syncContent
557
+ ? { content: sanitizeStreamingImageMarkers(record.content) }
558
+ : {}),
559
+ statusLine: status.text,
560
+ },
336
561
  });
337
562
  record.lastStatusSecond = status.second;
563
+ if (syncContent)
564
+ record.lastContentSyncSecond = status.second;
338
565
  record.consecutiveStatusFailures = 0;
339
566
  // A success revives the per-second chain even when only a low-frequency
340
567
  // breaker probe is scheduled.
@@ -350,13 +577,16 @@ export class StatusCardController {
350
577
  }
351
578
  }
352
579
  scheduleStatusRefresh(record, intervalMs = STATUS_REFRESH_INTERVAL_MS) {
353
- if (record.terminal || record.streamFailed || record.statusTimer)
580
+ if (this.disposed ||
581
+ record.terminal ||
582
+ record.streamFailed ||
583
+ record.statusTimer)
354
584
  return;
355
585
  const elapsed = Math.max(0, Date.now() - record.startedAt);
356
586
  const delay = Math.max(50, intervalMs - (elapsed % intervalMs));
357
587
  record.statusTimer = setTimeout(() => {
358
588
  record.statusTimer = undefined;
359
- if (record.terminal || record.streamFailed)
589
+ if (this.disposed || record.terminal || record.streamFailed)
360
590
  return;
361
591
  const refresh = record.writeChain.then(() => this.updateRunningStatus(record));
362
592
  record.writeChain = refresh;