crewly 1.20.273 → 1.20.274
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/config/skills/agent/core/ask-owner/SKILL.md +20 -6
- package/config/skills/agent/core/ask-owner/execute.sh +18 -6
- package/config/skills/agent/core/ask-owner/execute.test.sh +19 -1
- package/config/skills/agent/core/reply/SKILL.md +11 -0
- package/dist/backend/backend/src/constants.d.ts +16 -6
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +16 -6
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-card.js +4 -2
- package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-routing.js +1 -1
- package/dist/backend/backend/src/services/decisions/decision-routing.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +93 -2
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +266 -19
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts +12 -3
- package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-item-extractor.js +125 -11
- package/dist/backend/backend/src/services/open-items/open-item-extractor.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +7 -7
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.js +12 -21
- package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js +0 -8
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
- package/dist/backend/backend/src/types/decision.types.d.ts +10 -0
- package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +16 -6
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +16 -6
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision-card.js +4 -2
- package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +93 -2
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +266 -19
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts +12 -3
- package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-item-extractor.js +125 -11
- package/dist/cli/backend/src/services/open-items/open-item-extractor.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +7 -7
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.js +12 -21
- package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/cli/backend/src/types/decision.types.d.ts +10 -0
- package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -64,6 +64,48 @@ function scopeOf(d) {
|
|
|
64
64
|
asker: d.asker,
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The Slack thread a decision's card is in (or will go to): `thread:<channel>:<ts>`.
|
|
69
|
+
*
|
|
70
|
+
* @param d - Decision
|
|
71
|
+
* @returns Key, or null when it has no thread yet
|
|
72
|
+
*/
|
|
73
|
+
export function threadKeyOf(d) {
|
|
74
|
+
if (d.card)
|
|
75
|
+
return `thread:${d.card.slackChannelId}:${d.card.threadTs ?? d.card.messageTs}`;
|
|
76
|
+
if (d.place?.slackChannelId && d.place.threadTs)
|
|
77
|
+
return `thread:${d.place.slackChannelId}:${d.place.threadTs}`;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The topic keys of a decision: its Request, its ticket and its thread. Two
|
|
82
|
+
* decisions from the same asker that share one are about the same thing.
|
|
83
|
+
*
|
|
84
|
+
* @param d - Decision (or an ask about to be stored)
|
|
85
|
+
* @returns Keys
|
|
86
|
+
*/
|
|
87
|
+
export function topicKeys(d) {
|
|
88
|
+
const keys = [];
|
|
89
|
+
if (d.requestRef)
|
|
90
|
+
keys.push(`request:${d.requestRef.requestId}`);
|
|
91
|
+
if (d.ticket)
|
|
92
|
+
keys.push(`ticket:${d.ticket.projectPath}#${d.ticket.id}`);
|
|
93
|
+
const thread = threadKeyOf(d);
|
|
94
|
+
if (thread)
|
|
95
|
+
keys.push(thread);
|
|
96
|
+
return keys;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Cards the harness may close for the owner (an answer elsewhere settles
|
|
100
|
+
* them): plain ask-owner cards and question cards — never a sensitive,
|
|
101
|
+
* system, browser or app card, which need their own explicit answer.
|
|
102
|
+
*
|
|
103
|
+
* @param d - Decision
|
|
104
|
+
* @returns True when it may be closed as a duplicate
|
|
105
|
+
*/
|
|
106
|
+
function closableAsDuplicate(d) {
|
|
107
|
+
return !d.system && !d.sensitive && (!d.kind || d.kind === 'reply_question');
|
|
108
|
+
}
|
|
67
109
|
/**
|
|
68
110
|
* Next local `hour`:00 strictly tomorrow.
|
|
69
111
|
*
|
|
@@ -108,6 +150,8 @@ export class DecisionService {
|
|
|
108
150
|
* reminded: after an upgrade, old cards must not all ping the owner at once.
|
|
109
151
|
*/
|
|
110
152
|
startedAt = null;
|
|
153
|
+
/** Last time open cards were checked for what they track having closed (epoch ms) */
|
|
154
|
+
lastMootCheck = 0;
|
|
111
155
|
/**
|
|
112
156
|
* @param deps - Collaborators
|
|
113
157
|
*/
|
|
@@ -232,6 +276,14 @@ export class DecisionService {
|
|
|
232
276
|
}
|
|
233
277
|
if (!teamId)
|
|
234
278
|
teamId = await this.deps.teamOf(asker).catch(() => undefined);
|
|
279
|
+
// The same question already waits on an open card of this asker, in the
|
|
280
|
+
// same thread / ticket: that card stands — no second post.
|
|
281
|
+
const thread = await this.previewThreadKey(asker, ticket);
|
|
282
|
+
const same = await this.findOpenDuplicate(asker, ask.question, [...topicKeys({ ...(ticket ? { ticket } : {}) }), ...(thread ? [thread] : [])], (d) => !d.kind);
|
|
283
|
+
if (same) {
|
|
284
|
+
this.logger.info('Owner decision asked again while the same card is open — reusing it', { decisionId: same.id, asker });
|
|
285
|
+
return { ...same, reused: true };
|
|
286
|
+
}
|
|
235
287
|
const workItemId = callerSession ? await this.deps.currentWorkItemId?.(callerSession).catch(() => undefined) : undefined;
|
|
236
288
|
const decision = await this.deps.store.create({
|
|
237
289
|
question: ask.question,
|
|
@@ -309,6 +361,16 @@ export class DecisionService {
|
|
|
309
361
|
const skipped = await this.findSkipped({ ...(ask.requestRef ? { requestId: ask.requestRef.requestId } : {}), asker: ask.asker }, ask.question);
|
|
310
362
|
if (skipped)
|
|
311
363
|
throw this.alreadySkippedError(skipped);
|
|
364
|
+
if (ask.kind === 'reply_question') {
|
|
365
|
+
// A question the agent already has an open card for, here: no new card.
|
|
366
|
+
const thread = ask.place ? threadKeyOf({ place: ask.place }) : await this.previewThreadKey(ask.asker, undefined);
|
|
367
|
+
const keys = [...topicKeys({ ...(ask.requestRef ? { requestRef: ask.requestRef } : {}) }), ...(thread ? [thread] : [])];
|
|
368
|
+
const same = await this.findOpenDuplicate(ask.asker, ask.question, keys, closableAsDuplicate);
|
|
369
|
+
if (same) {
|
|
370
|
+
this.logger.info('Question asked again while its card is open — reusing it', { decisionId: same.id, asker: ask.asker });
|
|
371
|
+
return { ...same, reused: true };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
312
374
|
const teamId = await this.deps.teamOf(ask.asker).catch(() => undefined);
|
|
313
375
|
const workItemId = await this.deps.currentWorkItemId?.(ask.asker).catch(() => undefined);
|
|
314
376
|
const decision = await this.deps.store.create({
|
|
@@ -413,8 +475,18 @@ export class DecisionService {
|
|
|
413
475
|
}
|
|
414
476
|
}
|
|
415
477
|
/**
|
|
416
|
-
* Where the card goes
|
|
417
|
-
*
|
|
478
|
+
* Where the card goes — always the conversation the question belongs to:
|
|
479
|
+
*
|
|
480
|
+
* 1. the thread the question was asked in (`place`: reply questions);
|
|
481
|
+
* 2. the owner thread the asker owes right now (the owner is waiting there);
|
|
482
|
+
* 3. the ticket's thread, when it has one;
|
|
483
|
+
* 4. the thread of the asker's current work;
|
|
484
|
+
* 5. a new ticket thread (ticket asks);
|
|
485
|
+
* 6. top-level, only when there is no thread at all.
|
|
486
|
+
*
|
|
487
|
+
* The owner's conversation used to lose to the ticket thread: a ticket ask
|
|
488
|
+
* landed in an old ticket thread while the owner waited elsewhere
|
|
489
|
+
* (2026-10-08 card audit).
|
|
418
490
|
*/
|
|
419
491
|
async placeFor(decision, identity, slack) {
|
|
420
492
|
if (decision.system) {
|
|
@@ -423,35 +495,132 @@ export class DecisionService {
|
|
|
423
495
|
throw new DecisionError(409, "No Slack DM with the owner from this machine's orc bot");
|
|
424
496
|
return { slackChannelId: dm };
|
|
425
497
|
}
|
|
498
|
+
const team = decision.teamId ? { teamId: decision.teamId } : {};
|
|
499
|
+
if (decision.place?.slackChannelId)
|
|
500
|
+
return { ...decision.place, ...team };
|
|
501
|
+
const thread = await this.threadPlace(decision.asker, decision.ticket);
|
|
502
|
+
if (thread)
|
|
503
|
+
return { ...team, ...thread };
|
|
426
504
|
if (decision.ticket) {
|
|
427
505
|
const t = decision.ticket;
|
|
428
|
-
const existing = await this.deps.threads.get(t.projectPath, t.id);
|
|
429
|
-
if (existing)
|
|
430
|
-
return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, teamId: existing.teamId };
|
|
431
506
|
const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
|
|
432
507
|
if (!channel)
|
|
433
508
|
throw new DecisionError(409, `No Slack team channel for ${t.id}'s team — link the team to Slack first`);
|
|
434
509
|
const { ts } = await this.send(slack, identity, { channelId: channel, text: ticketThreadRootText(t) });
|
|
435
|
-
const
|
|
436
|
-
return { slackChannelId:
|
|
437
|
-
}
|
|
438
|
-
if (decision.place?.slackChannelId) {
|
|
439
|
-
return { ...decision.place, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
|
|
510
|
+
const created = await this.deps.threads.set(t.projectPath, t.id, { slackChannelId: channel, threadTs: ts, ...(decision.teamId ? { teamId: decision.teamId } : {}) });
|
|
511
|
+
return { slackChannelId: created.slackChannelId, threadTs: created.threadTs, teamId: created.teamId };
|
|
440
512
|
}
|
|
441
|
-
const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
|
|
442
|
-
if (work?.slackChannelId && work.threadTs)
|
|
443
|
-
return work;
|
|
444
|
-
// No thread for the work (a new top-level post in the team channel): the
|
|
445
|
-
// owner thread the asker owes is where the owner is looking.
|
|
446
513
|
const ownerThread = await this.deps.ownerThreadOf?.(decision.asker).catch(() => null);
|
|
447
514
|
if (ownerThread?.slackChannelId)
|
|
448
|
-
return { ...ownerThread, ...
|
|
515
|
+
return { ...ownerThread, ...team };
|
|
516
|
+
const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
|
|
449
517
|
if (work?.slackChannelId)
|
|
450
518
|
return work;
|
|
451
519
|
const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
|
|
452
520
|
if (!channel)
|
|
453
521
|
throw new DecisionError(409, `No Slack place to ask in: ${decision.asker} has no team channel and no Slack conversation in hand`);
|
|
454
|
-
return { slackChannelId: channel, ...
|
|
522
|
+
return { slackChannelId: channel, ...team };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The existing thread a card of `asker` goes to (steps 2–4 of
|
|
526
|
+
* {@link placeFor}), without creating anything.
|
|
527
|
+
*
|
|
528
|
+
* @param asker - Asking agent
|
|
529
|
+
* @param ticket - The ticket asked about, if any
|
|
530
|
+
* @returns The thread, or null when the card would start one / go top-level
|
|
531
|
+
*/
|
|
532
|
+
async threadPlace(asker, ticket) {
|
|
533
|
+
const ownerThread = await this.deps.ownerThreadOf?.(asker).catch(() => null);
|
|
534
|
+
if (ownerThread?.slackChannelId && ownerThread.threadTs)
|
|
535
|
+
return ownerThread;
|
|
536
|
+
if (ticket) {
|
|
537
|
+
const existing = await this.deps.threads.get(ticket.projectPath, ticket.id).catch(() => null);
|
|
538
|
+
if (existing)
|
|
539
|
+
return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, ...(existing.teamId ? { teamId: existing.teamId } : {}) };
|
|
540
|
+
}
|
|
541
|
+
const work = await this.deps.workDestination?.(asker).catch(() => null);
|
|
542
|
+
if (work?.slackChannelId && work.threadTs)
|
|
543
|
+
return work;
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The thread key a new card of `asker` would land in, for the duplicate check.
|
|
548
|
+
*
|
|
549
|
+
* @param asker - Asking agent
|
|
550
|
+
* @param ticket - The ticket asked about, if any
|
|
551
|
+
* @returns Key, or null
|
|
552
|
+
*/
|
|
553
|
+
async previewThreadKey(asker, ticket) {
|
|
554
|
+
const place = await this.threadPlace(asker, ticket).catch(() => null);
|
|
555
|
+
return place?.threadTs ? `thread:${place.slackChannelId}:${place.threadTs}` : null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* An open card of `asker` that shares a topic key and asks a similar
|
|
559
|
+
* question ({@link DECISION_CONSTANTS.SAME_OPEN_QUESTION_SIMILARITY}).
|
|
560
|
+
*
|
|
561
|
+
* @param asker - Asking agent
|
|
562
|
+
* @param question - The new question
|
|
563
|
+
* @param keys - Topic keys of the new ask ({@link topicKeys})
|
|
564
|
+
* @param eligible - Which open cards may stand in for it
|
|
565
|
+
* @returns The open card, or null
|
|
566
|
+
*/
|
|
567
|
+
async findOpenDuplicate(asker, question, keys, eligible) {
|
|
568
|
+
if (keys.length === 0 || !question.trim())
|
|
569
|
+
return null;
|
|
570
|
+
const want = new Set(keys);
|
|
571
|
+
const open = await this.deps.store.list((d) => d.status === 'open' &&
|
|
572
|
+
d.asker === asker &&
|
|
573
|
+
eligible(d) &&
|
|
574
|
+
topicKeys(d).some((k) => want.has(k)) &&
|
|
575
|
+
questionSimilarity(d.question, question) >= DECISION_CONSTANTS.SAME_OPEN_QUESTION_SIMILARITY);
|
|
576
|
+
return open.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The owner settled `d`: close every other open card of the same asker
|
|
580
|
+
* that the same answer settles —
|
|
581
|
+
*
|
|
582
|
+
* - a similar question in the same thread / ticket / request
|
|
583
|
+
* ("✓ Closed — answered in D-7");
|
|
584
|
+
* - when the owner answered in words in the thread: the asker's older
|
|
585
|
+
* question cards in that thread, which his message moved past
|
|
586
|
+
* ("✓ Closed — answered by your reply to D-7").
|
|
587
|
+
*
|
|
588
|
+
* Sensitive, system, browser and app cards are never closed this way.
|
|
589
|
+
*
|
|
590
|
+
* @param d - The settled decision
|
|
591
|
+
* @returns Ids closed
|
|
592
|
+
*/
|
|
593
|
+
async closeSiblings(d) {
|
|
594
|
+
if (!closableAsDuplicate(d))
|
|
595
|
+
return [];
|
|
596
|
+
const keys = new Set(topicKeys(d));
|
|
597
|
+
if (keys.size === 0)
|
|
598
|
+
return [];
|
|
599
|
+
const thread = threadKeyOf(d);
|
|
600
|
+
// Only words: a voice note / file does not say which card it answers (they stay open).
|
|
601
|
+
const words = d.status === 'resolved' && d.answeredVia === 'reply';
|
|
602
|
+
const answeredAt = Date.parse(d.resolvedAt ?? d.updatedAt);
|
|
603
|
+
const R = DECISION_CONSTANTS.CLOSED_REASONS;
|
|
604
|
+
const reasons = new Map();
|
|
605
|
+
const open = await this.deps.store.list((x) => x.id !== d.id && PENDING_DECISION_STATUSES.has(x.status) && x.asker === d.asker && closableAsDuplicate(x));
|
|
606
|
+
for (const x of open) {
|
|
607
|
+
if (!topicKeys(x).some((k) => keys.has(k)))
|
|
608
|
+
continue;
|
|
609
|
+
if (questionSimilarity(x.question, d.question) >= DECISION_CONSTANTS.SAME_OPEN_QUESTION_SIMILARITY) {
|
|
610
|
+
reasons.set(x.id, `${R.ANSWERED_IN_PREFIX}${d.id}`);
|
|
611
|
+
}
|
|
612
|
+
else if (words && x.kind === 'reply_question' && thread && threadKeyOf(x) === thread && Date.parse(x.createdAt) <= answeredAt) {
|
|
613
|
+
reasons.set(x.id, `${R.ANSWERED_BY_REPLY_PREFIX}${d.id}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const closed = [];
|
|
617
|
+
for (const [id, reason] of reasons) {
|
|
618
|
+
if ((await this.cancelWhere((x) => x.id === id, reason).catch(() => 0)) > 0)
|
|
619
|
+
closed.push(id);
|
|
620
|
+
}
|
|
621
|
+
if (closed.length > 0)
|
|
622
|
+
this.logger.info('Closed the open cards the same answer settles', { decisionId: d.id, closed });
|
|
623
|
+
return closed;
|
|
455
624
|
}
|
|
456
625
|
/**
|
|
457
626
|
* Send as the agent's own bot; when Slack refuses that bot (not in the
|
|
@@ -674,6 +843,9 @@ export class DecisionService {
|
|
|
674
843
|
for (const c of candidates) {
|
|
675
844
|
if (c.system || (target && c.id === target.id))
|
|
676
845
|
continue;
|
|
846
|
+
// Closed by the same answer (a duplicate of the target): nothing left to say.
|
|
847
|
+
if (target && (await this.deps.store.get(c.id))?.status === 'cancelled')
|
|
848
|
+
continue;
|
|
677
849
|
const why = c.sensitive
|
|
678
850
|
? `It needs the owner's explicit OK (${c.sensitive}), so it stays open — do not go ahead on this file alone.`
|
|
679
851
|
: c.kind === 'browser_action'
|
|
@@ -932,6 +1104,7 @@ export class DecisionService {
|
|
|
932
1104
|
if (!resolved)
|
|
933
1105
|
return { handled: false, reason: 'already settled', decision };
|
|
934
1106
|
await this.refreshCard(resolved);
|
|
1107
|
+
const siblings = await this.closeSiblings(resolved);
|
|
935
1108
|
const answer = resolved.chosenKey
|
|
936
1109
|
? optionLabel(resolved, resolved.chosenKey)
|
|
937
1110
|
: resolved.answeredVia === 'thread'
|
|
@@ -939,7 +1112,7 @@ export class DecisionService {
|
|
|
939
1112
|
: `“${resolved.answerText}”`;
|
|
940
1113
|
if (resolved.ticket)
|
|
941
1114
|
await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
|
|
942
|
-
await this.notifyAsker(resolved, this.answerNote(resolved)
|
|
1115
|
+
await this.notifyAsker(resolved, `${this.answerNote(resolved)}${siblingsLine(siblings)}`, batchNotes, { owner: true });
|
|
943
1116
|
this.closeWatchdog(resolved);
|
|
944
1117
|
this.logger.info('Owner decision resolved', { decisionId: resolved.id, via, chosen: resolved.chosenKey ?? 'text' });
|
|
945
1118
|
return { handled: true, reason: 'resolved', decision: resolved };
|
|
@@ -956,9 +1129,10 @@ export class DecisionService {
|
|
|
956
1129
|
if (!skipped)
|
|
957
1130
|
return { handled: false, reason: 'already settled', decision };
|
|
958
1131
|
await this.refreshCard(skipped);
|
|
1132
|
+
const siblings = await this.closeSiblings(skipped);
|
|
959
1133
|
if (skipped.ticket)
|
|
960
1134
|
await this.logTicket(skipped, `owner decision ${skipped.id}: skipped by the owner (${via})`, true);
|
|
961
|
-
await this.notifyAsker(skipped, this.skipNote(skipped)
|
|
1135
|
+
await this.notifyAsker(skipped, `${this.skipNote(skipped)}${siblingsLine(siblings)}`, batchNotes, { owner: true });
|
|
962
1136
|
this.closeWatchdog(skipped);
|
|
963
1137
|
this.logger.info('Owner decision skipped', { decisionId: skipped.id, via });
|
|
964
1138
|
return { handled: true, reason: 'skipped', decision: skipped };
|
|
@@ -1058,6 +1232,8 @@ export class DecisionService {
|
|
|
1058
1232
|
}
|
|
1059
1233
|
}
|
|
1060
1234
|
acted.push(...(await this.postWaitReminders(reminders, now)));
|
|
1235
|
+
acted.push(...(await this.retryCardSync(now)));
|
|
1236
|
+
acted.push(...(await this.withdrawMootCards(now)));
|
|
1061
1237
|
await this.deps.store.prune().catch(() => 0);
|
|
1062
1238
|
}
|
|
1063
1239
|
finally {
|
|
@@ -1087,6 +1263,50 @@ export class DecisionService {
|
|
|
1087
1263
|
await this.tellAsker(d, `[DECISION ${d.id}] Crewly closed your question "${d.question}" without asking the owner: ${reason}. Nothing was posted to the owner. If that is wrong and the work is still open, ask again with ask-owner.`);
|
|
1088
1264
|
return true;
|
|
1089
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Redraw cards Slack refused to update (a closed decision must not keep
|
|
1268
|
+
* live buttons), at most every {@link DECISION_CONSTANTS.CARD_SYNC_RETRY_MS}.
|
|
1269
|
+
*
|
|
1270
|
+
* @param now - Clock
|
|
1271
|
+
* @returns Ids redrawn
|
|
1272
|
+
*/
|
|
1273
|
+
async retryCardSync(now) {
|
|
1274
|
+
const due = await this.deps.store.list((d) => d.cardSyncPending === true && !!d.card && now.getTime() - Date.parse(d.updatedAt) >= DECISION_CONSTANTS.CARD_SYNC_RETRY_MS);
|
|
1275
|
+
const done = [];
|
|
1276
|
+
for (const d of due) {
|
|
1277
|
+
if (await this.refreshCard(d))
|
|
1278
|
+
done.push(d.id);
|
|
1279
|
+
}
|
|
1280
|
+
if (done.length > 0)
|
|
1281
|
+
this.logger.info('Redrew decision cards Slack had refused to update', { decisionIds: done });
|
|
1282
|
+
return done;
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Withdraw open cards whose tracked item / ticket has closed meanwhile (the
|
|
1286
|
+
* owner answered in words, the ticket finished, the item expired) — the
|
|
1287
|
+
* card closes in Slack instead of dangling. Silent: nothing is posted to the
|
|
1288
|
+
* owner and the asker is not woken. At most every {@link DECISION_CONSTANTS.MOOT_CHECK_MS}.
|
|
1289
|
+
*
|
|
1290
|
+
* @param now - Clock
|
|
1291
|
+
* @returns Ids withdrawn
|
|
1292
|
+
*/
|
|
1293
|
+
async withdrawMootCards(now) {
|
|
1294
|
+
if (!this.deps.trackedClosed || now.getTime() - this.lastMootCheck < DECISION_CONSTANTS.MOOT_CHECK_MS)
|
|
1295
|
+
return [];
|
|
1296
|
+
this.lastMootCheck = now.getTime();
|
|
1297
|
+
const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && !d.system && (!!d.requestRef || !!d.ticket));
|
|
1298
|
+
const done = [];
|
|
1299
|
+
for (const d of open) {
|
|
1300
|
+
const reason = await this.deps.trackedClosed(d).catch(() => null);
|
|
1301
|
+
if (!reason)
|
|
1302
|
+
continue;
|
|
1303
|
+
if ((await this.cancelWhere((x) => x.id === d.id, reason).catch(() => 0)) > 0)
|
|
1304
|
+
done.push(d.id);
|
|
1305
|
+
}
|
|
1306
|
+
if (done.length > 0)
|
|
1307
|
+
this.logger.info('Withdrew open cards whose question is already closed', { decisionIds: done });
|
|
1308
|
+
return done;
|
|
1309
|
+
}
|
|
1090
1310
|
/** Post the "Remind me tomorrow" reminder in the card's thread. */
|
|
1091
1311
|
async remindNow(d) {
|
|
1092
1312
|
if (await this.withdrawIfMoot(d))
|
|
@@ -1119,6 +1339,11 @@ export class DecisionService {
|
|
|
1119
1339
|
}
|
|
1120
1340
|
if (d.waitReminderAt || d.ownerRepliedAt)
|
|
1121
1341
|
return null;
|
|
1342
|
+
// A question Crewly lifted out of a reply never nudges the owner: he
|
|
1343
|
+
// answers in the thread when he wants to. Only explicit ask-owner cards
|
|
1344
|
+
// get the one reminder (2026-10-08: "Still waiting on you: 你自己知道吗?").
|
|
1345
|
+
if (d.kind === 'reply_question')
|
|
1346
|
+
return null;
|
|
1122
1347
|
if (this.startedAt !== null && Date.parse(d.deadlineNoticeAt) < this.startedAt) {
|
|
1123
1348
|
// Noticed before this process started (e.g. before the upgrade that
|
|
1124
1349
|
// added reminders): marked, never reminded — no burst on first boot.
|
|
@@ -1216,6 +1441,17 @@ export class DecisionService {
|
|
|
1216
1441
|
// ---------------------------------------------------------------------------
|
|
1217
1442
|
/** Re-render the card for the decision's current state (with the posting bot's token). */
|
|
1218
1443
|
async refreshCard(d) {
|
|
1444
|
+
if (!d.card)
|
|
1445
|
+
return false;
|
|
1446
|
+
const ok = await this.drawCard(d);
|
|
1447
|
+
// A failed redraw is retried on the tick (retryCardSync) until Slack takes it.
|
|
1448
|
+
await this.deps.store
|
|
1449
|
+
.update(d.id, (cur) => (ok ? (cur.cardSyncPending ? { cardSyncPending: undefined } : null) : cur.cardSyncPending ? null : { cardSyncPending: true }))
|
|
1450
|
+
.catch(() => null);
|
|
1451
|
+
return ok;
|
|
1452
|
+
}
|
|
1453
|
+
/** {@link refreshCard} without the retry bookkeeping. */
|
|
1454
|
+
async drawCard(d) {
|
|
1219
1455
|
const slack = this.deps.slack();
|
|
1220
1456
|
if (!d.card || !slack)
|
|
1221
1457
|
return false;
|
|
@@ -1418,6 +1654,17 @@ function systemChoiceFromText(d, text) {
|
|
|
1418
1654
|
}
|
|
1419
1655
|
return null;
|
|
1420
1656
|
}
|
|
1657
|
+
/**
|
|
1658
|
+
* The line telling the asker which of its other cards the answer closed.
|
|
1659
|
+
*
|
|
1660
|
+
* @param ids - Closed decision ids
|
|
1661
|
+
* @returns Text starting with a space, or '' for none
|
|
1662
|
+
*/
|
|
1663
|
+
function siblingsLine(ids) {
|
|
1664
|
+
if (ids.length === 0)
|
|
1665
|
+
return '';
|
|
1666
|
+
return ` Crewly closed your other open card${ids.length === 1 ? '' : 's'} on this (${ids.join(', ')}): this answer covers ${ids.length === 1 ? 'it' : 'them'} — don't ask again.`;
|
|
1667
|
+
}
|
|
1421
1668
|
/**
|
|
1422
1669
|
* The number of a `D-<n>` id (0 when unreadable), for ordering.
|
|
1423
1670
|
*
|