crewly 1.20.188 → 1.20.190
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/attach-file/execute.sh +13 -4
- package/config/skills/agent/core/complete-task/execute.sh +3 -0
- package/config/skills/agent/core/reply/SKILL.md +32 -4
- package/config/skills/agent/core/reply/execute.sh +23 -1
- package/config/skills/agent/core/reply/execute.test.sh +9 -0
- package/config/skills/agent/core/reply-chat/SKILL.md +9 -2
- package/config/skills/agent/core/reply-chat/execute.sh +13 -1
- package/config/skills/agent/core/report-status/execute.sh +5 -0
- package/config/skills/agent/core/send-chat-response/execute.sh +1 -1
- package/dist/backend/backend/src/constants.d.ts +23 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +27 -1
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +14 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +66 -78
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts +12 -0
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js +148 -24
- package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +62 -0
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.js +120 -35
- package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
- package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.d.ts +56 -0
- package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.d.ts.map +1 -0
- package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.js +114 -0
- package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.js.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +7 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +9 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +7 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +19 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +4 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +10 -3
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/memory/project-memory.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/memory/project-memory.service.js +21 -10
- package/dist/backend/backend/src/services/memory/project-memory.service.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +29 -0
- 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 +92 -8
- 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.js +2 -2
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.d.ts +81 -0
- package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.js +88 -0
- package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.js.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-reply-route.service.d.ts +8 -0
- package/dist/backend/backend/src/services/orc/orc-reply-route.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-reply-route.service.js +11 -0
- package/dist/backend/backend/src/services/orc/orc-reply-route.service.js.map +1 -1
- package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts +218 -0
- package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/reply-destination-resolver.js +343 -0
- package/dist/backend/backend/src/services/orc/reply-destination-resolver.js.map +1 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +98 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +362 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +9 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +32 -7
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +2 -2
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +9 -0
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js +4 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +24 -4
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts +89 -7
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js +159 -21
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts +23 -4
- package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-intake.service.js +42 -4
- package/dist/backend/backend/src/services/v3/ticket-intake.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +23 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +27 -1
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +7 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +9 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +7 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +19 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +4 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +10 -3
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/cli/backend/src/services/memory/project-memory.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/memory/project-memory.service.js +21 -10
- package/dist/cli/backend/src/services/memory/project-memory.service.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +457 -0
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/open-items/open-items.service.js +1158 -0
- package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -0
- package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.d.ts +81 -0
- package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.js +88 -0
- package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.js.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-reply-route.service.d.ts +8 -0
- package/dist/cli/backend/src/services/orc/orc-reply-route.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/orc/orc-reply-route.service.js +11 -0
- package/dist/cli/backend/src/services/orc/orc-reply-route.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +9 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +32 -7
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +24 -4
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts +89 -7
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js +159 -21
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts +23 -4
- package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-intake.service.js +42 -4
- package/dist/cli/backend/src/services/v3/ticket-intake.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open items — Crewly tracks what agents promise and ask the owner in their
|
|
3
|
+
* replies, so nothing is silently dropped (specs/2026-10-01-reply-open-items.md).
|
|
4
|
+
*
|
|
5
|
+
* Incident (TKT-185, 2026-10-01): in #book-publish Atlas told the owner "Kai is
|
|
6
|
+
* going through the four episodes… 明天中午给我,我核过以后挑最有用的几条发你"
|
|
7
|
+
* and asked "第 13 章「互评当体检用」这个读法,你同意吗?". The ticket was already
|
|
8
|
+
* closed. Kai finished at 18:14 and Atlas verified it, but nobody delivered
|
|
9
|
+
* it and Atlas went idle; the question was never tracked.
|
|
10
|
+
*
|
|
11
|
+
* Now, for every agent message in a ticket's conversation:
|
|
12
|
+
*
|
|
13
|
+
* - **Extract** commitments and owner questions (rules, {@link extractOpenItems}).
|
|
14
|
+
* - **Hold the ticket open**: items live on `Request.openItems`; while one is
|
|
15
|
+
* active the ticket is `awaiting_followup` (RequestService gates `done`).
|
|
16
|
+
* - **Commitments**: a follow-up WorkItem for the agent, due at the parsed
|
|
17
|
+
* time; the delegated child work is linked; when that work is finished
|
|
18
|
+
* before the agent delivers, the agent is woken at once ("the work you
|
|
19
|
+
* promised the owner is ready — deliver it now"); past the due time the
|
|
20
|
+
* agent is nudged once, then the owner is told in the thread what is late
|
|
21
|
+
* and why; the agent's next post in the thread once the work is ready
|
|
22
|
+
* marks it delivered.
|
|
23
|
+
* - **Questions**: a decision card in the same thread, asked as the agent,
|
|
24
|
+
* with options and a default derived from its words ({@link deriveQuestionCard});
|
|
25
|
+
* answers reach the agent through the decision flow. No second card when
|
|
26
|
+
* the agent already asked the same thing with ask-owner.
|
|
27
|
+
*
|
|
28
|
+
* Collaborators are injected ({@link OpenItemsDeps}); `open-items.wiring.ts`
|
|
29
|
+
* connects the real ones.
|
|
30
|
+
*
|
|
31
|
+
* @module services/open-items/open-items.service
|
|
32
|
+
*/
|
|
33
|
+
import { OPEN_ITEMS_CONSTANTS, REPLY_ROUTING_CONSTANTS } from '../../constants.js';
|
|
34
|
+
import { isInterim } from '../slack/slack-typing-placeholder.service.js';
|
|
35
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
36
|
+
import { ACTIVE_OPEN_ITEM_STATUSES } from '../../types/v2/open-item.types.js';
|
|
37
|
+
import { formatTicketNumber } from '../../types/v2/ticket.types.js';
|
|
38
|
+
import { extractOpenItems, parseDue } from './open-item-extractor.js';
|
|
39
|
+
import { deriveQuestionCard, questionSimilarity } from './open-item-card.js';
|
|
40
|
+
import { formatWhen } from '../decisions/decision-card.js';
|
|
41
|
+
import { AgentPromptReferenceService } from '../orc/agent-prompt-reference.service.js';
|
|
42
|
+
/** Thrown for open-item requests the caller must fix (HTTP 4xx). */
|
|
43
|
+
export class OpenItemsError extends Error {
|
|
44
|
+
status;
|
|
45
|
+
constructor(status, message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.status = status;
|
|
48
|
+
this.name = 'OpenItemsError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Statuses of a child WorkItem that count as finished work. */
|
|
52
|
+
const CHILD_DONE = new Set(['done', 'verified']);
|
|
53
|
+
/** Ticket statuses the service follows (`cancelled` closes items instead). */
|
|
54
|
+
const FOLLOWED = new Set(['open', 'ready', 'running', 'blocked', 'waiting_confirmation', 'awaiting_followup', 'done']);
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Pure helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* The Slack thread of a request's conversation (`origin.threadRef` is
|
|
60
|
+
* `slack:<channel>:<ts>`).
|
|
61
|
+
*
|
|
62
|
+
* @param request - Request
|
|
63
|
+
* @returns Place, or null for chat-only conversations
|
|
64
|
+
*/
|
|
65
|
+
export function slackPlaceOf(request) {
|
|
66
|
+
const ref = request.origin?.threadRef;
|
|
67
|
+
if (typeof ref !== 'string')
|
|
68
|
+
return null;
|
|
69
|
+
const m = /^slack:([CDG][A-Z0-9]+):(\d{6,}\.\d+)$/.exec(ref);
|
|
70
|
+
return m ? { slackChannelId: m[1], threadTs: m[2] } : null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether a message is in the request's conversation.
|
|
74
|
+
*
|
|
75
|
+
* @param request - Ticket with a chatRef
|
|
76
|
+
* @param message - chat-v2 message
|
|
77
|
+
* @returns True for its thread (or its root)
|
|
78
|
+
*/
|
|
79
|
+
export function inRequestThread(request, message) {
|
|
80
|
+
const ref = request.chatRef;
|
|
81
|
+
if (!ref || ref.channelId !== message.channelId)
|
|
82
|
+
return false;
|
|
83
|
+
const root = message.threadId ?? message.id;
|
|
84
|
+
return root === ref.threadRootId || root === ref.messageId;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether a WorkItem is bookkeeping (verify / follow-up) rather than work.
|
|
88
|
+
*
|
|
89
|
+
* @param wi - WorkItem
|
|
90
|
+
* @returns True to ignore it as child work
|
|
91
|
+
*/
|
|
92
|
+
function isBookkeeping(wi) {
|
|
93
|
+
const meta = (wi.metadata ?? {});
|
|
94
|
+
return (wi.type === 'review' ||
|
|
95
|
+
wi.id.includes(':verify:') ||
|
|
96
|
+
typeof meta.verifyOf === 'string' ||
|
|
97
|
+
meta[OPEN_ITEMS_CONSTANTS.FOLLOW_UP_METADATA_KEY] !== undefined);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether a WorkItem was started for this request's conversation: linked to
|
|
101
|
+
* the request, or (1.20.183 origin chain) carrying its thread as its origin.
|
|
102
|
+
*
|
|
103
|
+
* @param wi - WorkItem
|
|
104
|
+
* @param request - Request
|
|
105
|
+
* @returns True when it belongs to the request
|
|
106
|
+
*/
|
|
107
|
+
function belongsTo(wi, request) {
|
|
108
|
+
if (wi.requestId === request.id || request.workItemIds.includes(wi.id))
|
|
109
|
+
return true;
|
|
110
|
+
const origin = wi.metadata?.origin;
|
|
111
|
+
if (!origin || origin.kind !== 'owner')
|
|
112
|
+
return false;
|
|
113
|
+
if (request.chatRef && origin.chatThreadId === request.chatRef.threadRootId)
|
|
114
|
+
return true;
|
|
115
|
+
const place = slackPlaceOf(request);
|
|
116
|
+
return !!place && origin.slackChannelId === place.slackChannelId && origin.threadTs === place.threadTs;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The delegated child work a commitment waits on: WorkItems of the request
|
|
120
|
+
* given to someone else, started around the promise and not already finished
|
|
121
|
+
* before it.
|
|
122
|
+
*
|
|
123
|
+
* @param request - Request
|
|
124
|
+
* @param item - Commitment
|
|
125
|
+
* @param pool - Every WorkItem
|
|
126
|
+
* @returns Child WorkItem ids
|
|
127
|
+
*/
|
|
128
|
+
export function childWorkFor(request, item, pool) {
|
|
129
|
+
const at = Date.parse(item.createdAt);
|
|
130
|
+
const from = at - OPEN_ITEMS_CONSTANTS.CHILD_LOOKBACK_MS;
|
|
131
|
+
const to = at + OPEN_ITEMS_CONSTANTS.CHILD_LOOKAHEAD_MS;
|
|
132
|
+
const out = new Set();
|
|
133
|
+
for (const wi of pool) {
|
|
134
|
+
if (isBookkeeping(wi) || !belongsTo(wi, request))
|
|
135
|
+
continue;
|
|
136
|
+
if (!wi.target || wi.target === item.agent)
|
|
137
|
+
continue;
|
|
138
|
+
if (wi.status === 'cancelled')
|
|
139
|
+
continue;
|
|
140
|
+
const created = Date.parse(wi.createdAt);
|
|
141
|
+
if (!(created >= from && created <= to))
|
|
142
|
+
continue;
|
|
143
|
+
const finished = wi.completedAt ? Date.parse(wi.completedAt) : NaN;
|
|
144
|
+
if (CHILD_DONE.has(wi.status) && Number.isFinite(finished) && finished < at)
|
|
145
|
+
continue;
|
|
146
|
+
out.add(wi.id);
|
|
147
|
+
}
|
|
148
|
+
return [...out];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether every child of a commitment is finished (cancelled ones ignored).
|
|
152
|
+
*
|
|
153
|
+
* @param ids - Child ids
|
|
154
|
+
* @param pool - Every WorkItem
|
|
155
|
+
* @returns `ready` with the latest finish time, or not ready (with what is left)
|
|
156
|
+
*/
|
|
157
|
+
export function childrenState(ids, pool) {
|
|
158
|
+
const byId = new Map(pool.map((w) => [w.id, w]));
|
|
159
|
+
const pending = [];
|
|
160
|
+
const done = [];
|
|
161
|
+
let latest = 0;
|
|
162
|
+
for (const id of ids) {
|
|
163
|
+
const wi = byId.get(id);
|
|
164
|
+
if (!wi || wi.status === 'cancelled')
|
|
165
|
+
continue;
|
|
166
|
+
if (CHILD_DONE.has(wi.status)) {
|
|
167
|
+
done.push(wi);
|
|
168
|
+
latest = Math.max(latest, Date.parse(wi.completedAt ?? '') || 0);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
pending.push(wi);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const ready = done.length > 0 && pending.length === 0;
|
|
175
|
+
return { ready, ...(ready && latest > 0 ? { finishedAt: new Date(latest).toISOString() } : {}), pending, done };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Whether two items are the same thing said once: same message, or the same
|
|
179
|
+
* words by the same agent within {@link OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS}.
|
|
180
|
+
*
|
|
181
|
+
* @param a - Item
|
|
182
|
+
* @param b - Item
|
|
183
|
+
* @returns True for a duplicate
|
|
184
|
+
*/
|
|
185
|
+
export function isSameItem(a, b) {
|
|
186
|
+
if (a.text !== b.text)
|
|
187
|
+
return false;
|
|
188
|
+
if (a.sourceMessageId === b.sourceMessageId)
|
|
189
|
+
return true;
|
|
190
|
+
return a.agent === b.agent && Math.abs(Date.parse(a.createdAt) - Date.parse(b.createdAt)) <= OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS;
|
|
191
|
+
}
|
|
192
|
+
/** Whether the owner's message agrees ("可以", "同意", "ok", "go ahead", 👍) and does not hold back. */
|
|
193
|
+
export function isApproval(text) {
|
|
194
|
+
const t = text.replace(/<@[A-Z0-9]+>/g, ' ').trim().toLowerCase();
|
|
195
|
+
if (!t || t.length > 120)
|
|
196
|
+
return false;
|
|
197
|
+
if (/不(?:行|可以|同意|好|要|用|必)|别|先别|等等|再想想|\bno\b|don'?t|\bwait\b|\bhold\b|\bstop\b|不对/.test(t))
|
|
198
|
+
return false;
|
|
199
|
+
return /^(?:ok|okay|yes|yep|yeah|sure|go|go ahead|approved?|lgtm|agreed?|👍|✅|可以|好的?|行|同意|没问题|批准|确认|通过|发吧|做吧|开始吧?|就这样|按你说的)/u.test(t) || /(?:可以|同意|没问题|go ahead|approved?)[。!!.\s]*$/u.test(t);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Kinds of deliverable a promise can name; a post delivers such a promise
|
|
203
|
+
* only with a link, an attachment, or the same kind of thing named.
|
|
204
|
+
*/
|
|
205
|
+
const DELIVERABLE_FAMILIES = [
|
|
206
|
+
['预览', 'preview'],
|
|
207
|
+
['pdf'],
|
|
208
|
+
['链接', 'link', 'url'],
|
|
209
|
+
['报告', 'report'],
|
|
210
|
+
['结论', 'conclusion', 'finding'],
|
|
211
|
+
['文章', 'article', '稿', 'draft'],
|
|
212
|
+
['名单', 'list'],
|
|
213
|
+
['截图', 'screenshot', 'image'],
|
|
214
|
+
['文件', 'file', '附件', 'attachment', '表格', 'sheet'],
|
|
215
|
+
];
|
|
216
|
+
/** A post that only acknowledges or reports progress — not a delivery. */
|
|
217
|
+
const NOT_A_DELIVERY = /^\s*(?:(?:收到|好的?|ok(?:ay)?|got it|on it|嗯+|明白)[\s,,。.!!~]*$|(?:在做|正在|还在|马上|稍等|working on|still working|in progress))/i;
|
|
218
|
+
/**
|
|
219
|
+
* Whether a post plausibly fulfils a promise (specs/2026-10-02-harness-owned-routing.md §5):
|
|
220
|
+
* the harness marked it as the ticket's delivery; or, for a promise of a
|
|
221
|
+
* deliverable (preview, PDF, link, report, file…), it carries a link, an
|
|
222
|
+
* attachment or names the same kind of thing; or, for a plain promise, it
|
|
223
|
+
* is a substantive post — not an ack, a progress line, or only a question.
|
|
224
|
+
*
|
|
225
|
+
* @param promise - The commitment's words
|
|
226
|
+
* @param message - The post
|
|
227
|
+
* @returns True when it can be the delivery
|
|
228
|
+
*/
|
|
229
|
+
export function plausiblyFulfils(promise, message) {
|
|
230
|
+
const meta = message.metadata ?? {};
|
|
231
|
+
if (meta[REPLY_ROUTING_CONSTANTS.DELIVERS_TICKET_METADATA_KEY])
|
|
232
|
+
return true;
|
|
233
|
+
const text = (message.content ?? '').trim();
|
|
234
|
+
if (!text)
|
|
235
|
+
return false;
|
|
236
|
+
const hasLink = /https?:\/\/\S+/i.test(text) || /<https?:[^>]+>/i.test(text);
|
|
237
|
+
const hasAttachment = (Array.isArray(meta.attachments) && meta.attachments.length > 0) ||
|
|
238
|
+
(Array.isArray(meta.files) && meta.files.length > 0) ||
|
|
239
|
+
/\[file uploaded:/i.test(text);
|
|
240
|
+
const p = promise.toLowerCase();
|
|
241
|
+
const t = text.toLowerCase();
|
|
242
|
+
const families = DELIVERABLE_FAMILIES.filter((f) => f.some((w) => p.includes(w)));
|
|
243
|
+
if (families.length > 0) {
|
|
244
|
+
return hasLink || hasAttachment || families.some((f) => f.some((w) => t.includes(w)));
|
|
245
|
+
}
|
|
246
|
+
if (hasLink || hasAttachment)
|
|
247
|
+
return true;
|
|
248
|
+
if (NOT_A_DELIVERY.test(text))
|
|
249
|
+
return false;
|
|
250
|
+
// Only a question back ("where should I send it?") is not the delivery.
|
|
251
|
+
const sentences = text.split(/[。.!!\n]+/).map((x) => x.trim()).filter(Boolean);
|
|
252
|
+
if (sentences.length > 0 && sentences.every((x) => /[??]$/.test(x)))
|
|
253
|
+
return false;
|
|
254
|
+
return text.length >= OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_CHARS;
|
|
255
|
+
}
|
|
256
|
+
/** Named people / tools a promise mentions (Nova, Vera, CDC…). */
|
|
257
|
+
function namesIn(s) {
|
|
258
|
+
return new Set((s.match(/\b[A-Z][A-Za-z]{2,}\b/g) ?? []).map((n) => n.toLowerCase()));
|
|
259
|
+
}
|
|
260
|
+
const DELIVERABLE_NOUNS = ['预览', 'preview', 'pdf', '报告', '结论', '文章', '稿', '名单', 'report', 'draft'];
|
|
261
|
+
/**
|
|
262
|
+
* Whether two commitments are the same promise: the same words, or — from the
|
|
263
|
+
* same agent within {@link OPEN_ITEMS_CONSTANTS.PROMISE_DUPLICATE_WINDOW_MS} —
|
|
264
|
+
* very similar words, or the same deliverable (same person named and the same
|
|
265
|
+
* kind of thing handed over).
|
|
266
|
+
*
|
|
267
|
+
* @param a - One item
|
|
268
|
+
* @param b - The other
|
|
269
|
+
* @returns True for one promise said twice
|
|
270
|
+
*/
|
|
271
|
+
export function isSamePromise(a, b) {
|
|
272
|
+
if (a.type !== 'commitment' || b.type !== 'commitment')
|
|
273
|
+
return false;
|
|
274
|
+
if (isSameItem(a, b))
|
|
275
|
+
return true;
|
|
276
|
+
if (a.agent !== b.agent || Math.abs(Date.parse(a.createdAt) - Date.parse(b.createdAt)) > OPEN_ITEMS_CONSTANTS.PROMISE_DUPLICATE_WINDOW_MS)
|
|
277
|
+
return false;
|
|
278
|
+
if (questionSimilarity(a.text, b.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY)
|
|
279
|
+
return true;
|
|
280
|
+
const na = namesIn(a.text);
|
|
281
|
+
const shared = [...namesIn(b.text)].some((n) => na.has(n));
|
|
282
|
+
const la = a.text.toLowerCase();
|
|
283
|
+
const lb = b.text.toLowerCase();
|
|
284
|
+
return shared && DELIVERABLE_NOUNS.some((w) => la.includes(w) && lb.includes(w));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Clip for a one-line mention.
|
|
288
|
+
*
|
|
289
|
+
* @param s - Text
|
|
290
|
+
* @param max - Max characters
|
|
291
|
+
* @returns Clipped
|
|
292
|
+
*/
|
|
293
|
+
function short(s, max = 120) {
|
|
294
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
295
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The reference a follow-up prompt names (specs/2026-10-02-harness-owned-routing.md §4).
|
|
299
|
+
*
|
|
300
|
+
* @param request - Request
|
|
301
|
+
* @param item - Commitment
|
|
302
|
+
* @returns The ticket (and follow-up work item) the agent answers about
|
|
303
|
+
*/
|
|
304
|
+
export function followUpReference(request, item) {
|
|
305
|
+
return {
|
|
306
|
+
...(typeof request.ticketNumber === 'number' ? { ticket: formatTicketNumber(request.ticketNumber) } : {}),
|
|
307
|
+
...(item.workItemId ? { workItemId: item.workItemId } : {}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The exact command a follow-up prompt tells the agent to run — a reference,
|
|
312
|
+
* never a raw thread key (the harness finds the thread).
|
|
313
|
+
*
|
|
314
|
+
* @param ref - Follow-up reference
|
|
315
|
+
* @returns Command text
|
|
316
|
+
*/
|
|
317
|
+
export function followUpCommand(ref) {
|
|
318
|
+
if (ref.ticket)
|
|
319
|
+
return `reply --ticket ${ref.ticket} "<your message>"`;
|
|
320
|
+
if (ref.workItemId)
|
|
321
|
+
return `reply --work-item ${ref.workItemId} "<your message>"`;
|
|
322
|
+
return 'reply "<your message>"';
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The ticket's display id.
|
|
326
|
+
*
|
|
327
|
+
* @param request - Request
|
|
328
|
+
* @returns `TKT-185`, else the id prefix
|
|
329
|
+
*/
|
|
330
|
+
function ticketLabel(request) {
|
|
331
|
+
return typeof request.ticketNumber === 'number' ? formatTicketNumber(request.ticketNumber) : request.id.slice(0, 8);
|
|
332
|
+
}
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Service
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
/**
|
|
337
|
+
* Tracks commitments and questions on Requests. See module docs.
|
|
338
|
+
*/
|
|
339
|
+
export class OpenItemsService {
|
|
340
|
+
deps;
|
|
341
|
+
static instance = null;
|
|
342
|
+
logger;
|
|
343
|
+
now;
|
|
344
|
+
chain = Promise.resolve();
|
|
345
|
+
timer = null;
|
|
346
|
+
/**
|
|
347
|
+
* @param deps - Collaborators
|
|
348
|
+
*/
|
|
349
|
+
constructor(deps) {
|
|
350
|
+
this.deps = deps;
|
|
351
|
+
this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('OpenItems');
|
|
352
|
+
this.now = deps.now ?? (() => new Date());
|
|
353
|
+
}
|
|
354
|
+
/** @returns The process-wide instance, or null before wiring */
|
|
355
|
+
static getInstance() {
|
|
356
|
+
return OpenItemsService.instance;
|
|
357
|
+
}
|
|
358
|
+
/** @param service - Instance to install (null clears) */
|
|
359
|
+
static setInstance(service) {
|
|
360
|
+
OpenItemsService.instance = service;
|
|
361
|
+
}
|
|
362
|
+
/** Start the periodic sweep. */
|
|
363
|
+
start() {
|
|
364
|
+
if (this.timer)
|
|
365
|
+
return;
|
|
366
|
+
this.timer = setInterval(() => {
|
|
367
|
+
void this.sweep().catch((err) => this.logger.warn('Open-items sweep failed', { error: errText(err) }));
|
|
368
|
+
}, OPEN_ITEMS_CONSTANTS.SWEEP_INTERVAL_MS);
|
|
369
|
+
this.timer.unref?.();
|
|
370
|
+
}
|
|
371
|
+
/** Stop the sweep. */
|
|
372
|
+
stop() {
|
|
373
|
+
if (this.timer)
|
|
374
|
+
clearInterval(this.timer);
|
|
375
|
+
this.timer = null;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* One change at a time (a chat message, a WorkItem event and the sweep may
|
|
379
|
+
* touch the same request).
|
|
380
|
+
*/
|
|
381
|
+
serial(fn) {
|
|
382
|
+
const run = this.chain.then(fn);
|
|
383
|
+
this.chain = run.catch(() => undefined);
|
|
384
|
+
return run;
|
|
385
|
+
}
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// Agent replies
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
/**
|
|
390
|
+
* The ticket an agent message belongs to: the one whose thread it is in
|
|
391
|
+
* (any status but cancelled, closed within the lookback), else — for a
|
|
392
|
+
* top-level message — the newest open ticket in that channel the agent is
|
|
393
|
+
* answering.
|
|
394
|
+
*
|
|
395
|
+
* @param message - chat-v2 message
|
|
396
|
+
* @param all - Every request
|
|
397
|
+
* @returns The request, or null
|
|
398
|
+
*/
|
|
399
|
+
findRequestFor(message, all) {
|
|
400
|
+
const since = this.now().getTime() - OPEN_ITEMS_CONSTANTS.LOOKBACK_MS;
|
|
401
|
+
const at = message.createdAt ?? this.now().getTime();
|
|
402
|
+
const tickets = all
|
|
403
|
+
.filter((r) => typeof r.ticketNumber === 'number' && !!r.chatRef && FOLLOWED.has(r.status) && Date.parse(r.updatedAt) >= since)
|
|
404
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
405
|
+
// Several tickets can share one thread (follow-up asks): the message
|
|
406
|
+
// belongs to the newest one opened before it — the open one first.
|
|
407
|
+
const sharing = tickets.filter((r) => inRequestThread(r, message));
|
|
408
|
+
const openedBefore = sharing.filter((r) => Date.parse(r.createdAt) <= at);
|
|
409
|
+
const inThread = openedBefore.length > 0 ? openedBefore : sharing;
|
|
410
|
+
if (inThread.length > 0)
|
|
411
|
+
return inThread.find((r) => r.status !== 'done') ?? inThread[0];
|
|
412
|
+
if (message.threadId)
|
|
413
|
+
return null;
|
|
414
|
+
return (tickets.find((r) => r.chatRef?.channelId === message.channelId &&
|
|
415
|
+
r.status !== 'done' &&
|
|
416
|
+
(r.assignee === message.senderId || r.reply?.by === message.senderId)) ?? null);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* An agent posted in chat-v2: mark what it delivered, and track what it
|
|
420
|
+
* newly promised or asked.
|
|
421
|
+
*
|
|
422
|
+
* @param message - The message
|
|
423
|
+
* @returns The request it changed, or null
|
|
424
|
+
*/
|
|
425
|
+
async onAgentMessage(message) {
|
|
426
|
+
if (message.senderType === 'user')
|
|
427
|
+
return this.onOwnerMessage(message);
|
|
428
|
+
if (message.senderType !== 'agent' || !message.content?.trim())
|
|
429
|
+
return null;
|
|
430
|
+
// "Got it — on it" placeholders are not the reply.
|
|
431
|
+
if (isInterim(message))
|
|
432
|
+
return null;
|
|
433
|
+
return this.serial(async () => {
|
|
434
|
+
const all = await this.deps.requests.listAll();
|
|
435
|
+
const request = this.findRequestFor(message, all);
|
|
436
|
+
if (!request)
|
|
437
|
+
return null;
|
|
438
|
+
const at = new Date(message.createdAt ?? this.now().getTime());
|
|
439
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
440
|
+
const planned = await this.plan(request, message, at);
|
|
441
|
+
let items = [...(request.openItems ?? [])];
|
|
442
|
+
let changed = false;
|
|
443
|
+
// Deliveries first: this post may be what an earlier promise was waiting for.
|
|
444
|
+
const delivered = this.deliveries(items, message, at, pool);
|
|
445
|
+
for (const d of delivered) {
|
|
446
|
+
items = items.map((i) => (i.id === d.id ? d : i));
|
|
447
|
+
changed = true;
|
|
448
|
+
if (d.workItemId)
|
|
449
|
+
await this.closeFollowUp(d.workItemId, 'delivered', `Delivered to the owner (message ${message.id})`);
|
|
450
|
+
this.logger.info('Commitment delivered', { tkt: ticketLabel(request), item: d.id, by: message.senderId });
|
|
451
|
+
}
|
|
452
|
+
// The same words twice (a reply recorded both from the reply path and the
|
|
453
|
+
// Slack mirror) are one item.
|
|
454
|
+
// A post that delivers something restates it ("结论在下面:…"): the words are
|
|
455
|
+
// the delivery, not a new promise.
|
|
456
|
+
const restated = (p) => p.item.type === 'commitment' &&
|
|
457
|
+
delivered.some((d) => questionSimilarity(d.text, p.item.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY);
|
|
458
|
+
const fresh = planned.filter((p) => !items.some((i) => isSameItem(i, p.item)) && !restated(p));
|
|
459
|
+
for (const p of fresh) {
|
|
460
|
+
// The same promise said again (a plan, then "收到,按刚才说的做"): the newest one stands.
|
|
461
|
+
for (let k = 0; k < items.length; k++) {
|
|
462
|
+
if (isSamePromise(items[k], p.item) && ACTIVE_OPEN_ITEM_STATUSES.has(items[k].status)) {
|
|
463
|
+
if (items[k].workItemId)
|
|
464
|
+
await this.closeFollowUp(items[k].workItemId, 'cancelled', `Superseded by a newer promise (${p.item.id})`);
|
|
465
|
+
items[k] = { ...items[k], status: 'superseded', closedAt: at.toISOString(), closedReason: `said again in message ${message.id}` };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const item = await this.activate(request, p, pool);
|
|
469
|
+
items.push(item);
|
|
470
|
+
changed = true;
|
|
471
|
+
}
|
|
472
|
+
if (!changed)
|
|
473
|
+
return null;
|
|
474
|
+
return this.save(request, items, fresh.length > 0);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The owner posted in a ticket's thread: a yes opens the conditional
|
|
479
|
+
* promises that wait on them.
|
|
480
|
+
*
|
|
481
|
+
* @param message - The owner's message
|
|
482
|
+
* @returns The request it changed, or null
|
|
483
|
+
*/
|
|
484
|
+
async onOwnerMessage(message) {
|
|
485
|
+
if (!message.content?.trim() || !isApproval(message.content))
|
|
486
|
+
return null;
|
|
487
|
+
return this.serial(async () => {
|
|
488
|
+
const all = await this.deps.requests.listAll();
|
|
489
|
+
const request = all.find((r) => r.status !== 'cancelled' && inRequestThread(r, message) && (r.openItems ?? []).some((i) => i.status === 'waiting_owner'));
|
|
490
|
+
if (!request)
|
|
491
|
+
return null;
|
|
492
|
+
const at = new Date(message.createdAt ?? this.now().getTime());
|
|
493
|
+
return this.openWaiting(request, at, `the owner said yes in the thread (message ${message.id})`, () => true);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Open waiting promises: their due time is counted from `at`, and the
|
|
498
|
+
* follow-up WorkItem is created now.
|
|
499
|
+
*
|
|
500
|
+
* @param request - Request
|
|
501
|
+
* @param at - When the owner agreed
|
|
502
|
+
* @param why - For the log
|
|
503
|
+
* @param pick - Which waiting items
|
|
504
|
+
* @returns The updated request, or null when none matched
|
|
505
|
+
*/
|
|
506
|
+
async openWaiting(request, at, why, pick) {
|
|
507
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
508
|
+
const items = [];
|
|
509
|
+
let changed = false;
|
|
510
|
+
for (const i of request.openItems ?? []) {
|
|
511
|
+
if (i.status !== 'waiting_owner' || !pick(i)) {
|
|
512
|
+
items.push(i);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const { due, source } = parseDue(i.text, at);
|
|
516
|
+
const opened = await this.activate(request, { item: { ...i, status: 'open', due: due.toISOString(), dueSource: source } }, pool);
|
|
517
|
+
this.logger.info('Conditional promise opened', { tkt: ticketLabel(request), item: i.id, due: opened.due, why });
|
|
518
|
+
items.push(opened);
|
|
519
|
+
changed = true;
|
|
520
|
+
}
|
|
521
|
+
return changed ? this.save(request, items, false) : null;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* What a message would add, without side effects (also the backfill's view).
|
|
525
|
+
*
|
|
526
|
+
* @param request - Its request
|
|
527
|
+
* @param message - The agent message
|
|
528
|
+
* @param at - When it was posted
|
|
529
|
+
* @returns Planned items
|
|
530
|
+
*/
|
|
531
|
+
async plan(request, message, at) {
|
|
532
|
+
const names = await this.deps.colleagueNames?.(message.senderId).catch(() => []);
|
|
533
|
+
const owner = this.deps.ownerSlackUserId?.() ?? (request.origin?.author?.startsWith('U') ? request.origin.author : null);
|
|
534
|
+
const found = extractOpenItems(message.content, {
|
|
535
|
+
now: at,
|
|
536
|
+
...(names ? { colleagueNames: names } : {}),
|
|
537
|
+
...(owner ? { ownerSlackUserId: owner } : {}),
|
|
538
|
+
});
|
|
539
|
+
const base = (type, n) => ({
|
|
540
|
+
id: `${type === 'commitment' ? 'c' : 'q'}-${message.id.slice(0, 8)}-${n}`,
|
|
541
|
+
agent: message.senderId,
|
|
542
|
+
sourceMessageId: message.id,
|
|
543
|
+
createdAt: at.toISOString(),
|
|
544
|
+
status: 'open',
|
|
545
|
+
});
|
|
546
|
+
const out = [];
|
|
547
|
+
for (const [i, c] of found.commitments.entries()) {
|
|
548
|
+
if (c.waitsOnOwner) {
|
|
549
|
+
// Conditional on the owner: no due time, no follow-up, no nudges until they say yes.
|
|
550
|
+
const gate = await this.recentDecision(message.senderId, at);
|
|
551
|
+
out.push({ item: { ...base('commitment', i + 1), type: 'commitment', text: c.text, status: 'waiting_owner', ...(gate ? { gateDecisionId: gate.id } : {}) } });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
out.push({ item: { ...base('commitment', i + 1), type: 'commitment', text: c.text, due: c.due.toISOString(), dueSource: c.dueSource } });
|
|
555
|
+
}
|
|
556
|
+
let qi = 0;
|
|
557
|
+
for (const q of found.questions) {
|
|
558
|
+
qi += 1;
|
|
559
|
+
const item = { ...base('question', qi), type: 'question', text: q.text };
|
|
560
|
+
const skipped = await this.deps.skippedQuestion?.(request.id, message.senderId, q.text).catch(() => null);
|
|
561
|
+
if (skipped) {
|
|
562
|
+
out.push({ item, skippedDecisionId: skipped.id });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const asked = await this.alreadyAsked(message.senderId, q, at);
|
|
566
|
+
out.push(asked ? { item, linkedDecisionId: asked.id } : { item, card: deriveQuestionCard(q) });
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The newest ask-owner decision this agent made shortly before `at` (the card a conditional promise waits on).
|
|
572
|
+
*
|
|
573
|
+
* @param agent - Agent
|
|
574
|
+
* @param at - When the promise was made
|
|
575
|
+
* @returns The decision, or null
|
|
576
|
+
*/
|
|
577
|
+
async recentDecision(agent, at) {
|
|
578
|
+
if (!this.deps.recentDecisionsBy)
|
|
579
|
+
return null;
|
|
580
|
+
const win = OPEN_ITEMS_CONSTANTS.ASK_OWNER_DEDUPE_WINDOW_MS;
|
|
581
|
+
const recent = await this.deps.recentDecisionsBy(agent, at.getTime() - win).catch(() => []);
|
|
582
|
+
return recent.filter((d) => d.kind !== 'reply_question' && Date.parse(d.createdAt) <= at.getTime() + 60_000).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* The ask-owner decision the agent already made for this question, if any.
|
|
586
|
+
*
|
|
587
|
+
* @param agent - Asking agent
|
|
588
|
+
* @param q - The question
|
|
589
|
+
* @param at - When it was asked in the reply
|
|
590
|
+
* @returns The decision, or null
|
|
591
|
+
*/
|
|
592
|
+
async alreadyAsked(agent, q, at) {
|
|
593
|
+
if (!this.deps.recentDecisionsBy)
|
|
594
|
+
return null;
|
|
595
|
+
const win = OPEN_ITEMS_CONSTANTS.ASK_OWNER_DEDUPE_WINDOW_MS;
|
|
596
|
+
const recent = await this.deps.recentDecisionsBy(agent, at.getTime() - win).catch(() => []);
|
|
597
|
+
return (recent.find((d) => d.kind !== 'reply_question' &&
|
|
598
|
+
Math.abs(Date.parse(d.createdAt) - at.getTime()) <= win &&
|
|
599
|
+
questionSimilarity(d.question, q.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY) ?? null);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Turn a planned item into a tracked one: link child work and queue the
|
|
603
|
+
* follow-up (commitment), or post / link the card (question).
|
|
604
|
+
*
|
|
605
|
+
* @param request - Its request
|
|
606
|
+
* @param p - Planned item
|
|
607
|
+
* @param pool - Every WorkItem
|
|
608
|
+
* @returns The stored item
|
|
609
|
+
*/
|
|
610
|
+
async activate(request, p, pool, source = 'live') {
|
|
611
|
+
const place = slackPlaceOf(request);
|
|
612
|
+
let item = { ...p.item };
|
|
613
|
+
if (p.skippedDecisionId) {
|
|
614
|
+
this.logger.info('Question the owner already skipped — not asked again', { tkt: ticketLabel(request), decisionId: p.skippedDecisionId });
|
|
615
|
+
return { ...item, status: 'skipped', decisionId: p.skippedDecisionId, closedAt: this.now().toISOString(), closedReason: `owner skipped this before (${p.skippedDecisionId})` };
|
|
616
|
+
}
|
|
617
|
+
if (item.type === 'commitment' && item.status === 'waiting_owner') {
|
|
618
|
+
this.logger.info('Conditional promise — waiting on the owner', { tkt: ticketLabel(request), item: item.id, agent: item.agent, gate: item.gateDecisionId });
|
|
619
|
+
return item;
|
|
620
|
+
}
|
|
621
|
+
if (item.type === 'commitment') {
|
|
622
|
+
const children = childWorkFor(request, item, pool);
|
|
623
|
+
if (children.length > 0)
|
|
624
|
+
item.childWorkItemIds = children;
|
|
625
|
+
const wiId = await this.deps.createFollowUp?.({ request, item, place }).catch((err) => {
|
|
626
|
+
this.logger.warn('Follow-up WorkItem not created', { tkt: ticketLabel(request), error: errText(err) });
|
|
627
|
+
return null;
|
|
628
|
+
});
|
|
629
|
+
if (wiId)
|
|
630
|
+
item.workItemId = wiId;
|
|
631
|
+
this.logger.info('Commitment tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, due: item.due, children: children.length });
|
|
632
|
+
// The child work may already be finished (the promise came late).
|
|
633
|
+
const state = childrenState(item.childWorkItemIds ?? [], pool);
|
|
634
|
+
if (state.ready)
|
|
635
|
+
item = await this.markReady(request, item, state.finishedAt, state.done);
|
|
636
|
+
return item;
|
|
637
|
+
}
|
|
638
|
+
if (p.linkedDecisionId) {
|
|
639
|
+
item.decisionId = p.linkedDecisionId;
|
|
640
|
+
this.logger.info('Question already asked with ask-owner — no second card', { tkt: ticketLabel(request), decisionId: p.linkedDecisionId });
|
|
641
|
+
return item;
|
|
642
|
+
}
|
|
643
|
+
if (p.card && this.deps.askQuestion) {
|
|
644
|
+
const d = await this.deps.askQuestion({ request, item, card: p.card, place, source }).catch((err) => {
|
|
645
|
+
this.logger.warn('Question card not posted', { tkt: ticketLabel(request), error: errText(err) });
|
|
646
|
+
return null;
|
|
647
|
+
});
|
|
648
|
+
if (d)
|
|
649
|
+
item.decisionId = d.id;
|
|
650
|
+
}
|
|
651
|
+
this.logger.info('Question tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, decisionId: item.decisionId });
|
|
652
|
+
return item;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Commitments this message delivers.
|
|
656
|
+
*
|
|
657
|
+
* @param items - Current items
|
|
658
|
+
* @param message - The post
|
|
659
|
+
* @param at - Its time
|
|
660
|
+
* @param pool - Every WorkItem
|
|
661
|
+
* @returns Updated (delivered) items
|
|
662
|
+
*/
|
|
663
|
+
deliveries(items, message, at, pool) {
|
|
664
|
+
const out = [];
|
|
665
|
+
for (const item of items) {
|
|
666
|
+
if (item.type !== 'commitment' || !ACTIVE_OPEN_ITEM_STATUSES.has(item.status) || item.status === 'waiting_owner')
|
|
667
|
+
continue;
|
|
668
|
+
if (!this.deliveredBy(item, { ...message, createdAt: at.getTime() }, pool))
|
|
669
|
+
continue;
|
|
670
|
+
out.push({ ...item, status: 'delivered', closedAt: at.toISOString(), closedReason: `posted in the thread (message ${message.id})` });
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Whether a post in the thread delivers a commitment: by the promising
|
|
676
|
+
* agent (or one doing the child work), once the child work is finished.
|
|
677
|
+
* With no child work: a later post by the promising agent, at least
|
|
678
|
+
* {@link OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS} after the promise, that
|
|
679
|
+
* does not itself promise something new.
|
|
680
|
+
*
|
|
681
|
+
* @param item - Commitment
|
|
682
|
+
* @param message - The post (`createdAt` set)
|
|
683
|
+
* @param pool - Every WorkItem
|
|
684
|
+
* @returns True when it is the delivery
|
|
685
|
+
*/
|
|
686
|
+
deliveredBy(item, message, pool) {
|
|
687
|
+
if (item.type !== 'commitment' || item.sourceMessageId === message.id)
|
|
688
|
+
return false;
|
|
689
|
+
if (message.senderType !== 'agent' || isInterim(message))
|
|
690
|
+
return false;
|
|
691
|
+
const at = message.createdAt ?? this.now().getTime();
|
|
692
|
+
if (at <= Date.parse(item.createdAt))
|
|
693
|
+
return false;
|
|
694
|
+
const children = item.childWorkItemIds ?? [];
|
|
695
|
+
if (children.length > 0) {
|
|
696
|
+
const childTargets = new Set(pool.filter((w) => children.includes(w.id)).map((w) => w.target));
|
|
697
|
+
if (message.senderId !== item.agent && !childTargets.has(message.senderId))
|
|
698
|
+
return false;
|
|
699
|
+
const state = childrenState(children, pool);
|
|
700
|
+
if (!state.ready)
|
|
701
|
+
return false;
|
|
702
|
+
// Posted after the work was finished. A verify pass can stamp the child's
|
|
703
|
+
// completedAt after the agent has posted, so allow a grace before it.
|
|
704
|
+
const postedBefore = state.finishedAt ? Date.parse(state.finishedAt) - at : 0;
|
|
705
|
+
if (postedBefore > OPEN_ITEMS_CONSTANTS.DELIVERY_FINISH_GRACE_MS)
|
|
706
|
+
return false;
|
|
707
|
+
// Only a post that plausibly IS the promised thing (spec 2026-10-02 §5).
|
|
708
|
+
if (!plausiblyFulfils(item.text, message))
|
|
709
|
+
return false;
|
|
710
|
+
// A post that makes a new promise is not the delivery.
|
|
711
|
+
return postedBefore <= 0 || !this.promisesAnew(item, message, at);
|
|
712
|
+
}
|
|
713
|
+
if (message.senderId !== item.agent)
|
|
714
|
+
return false;
|
|
715
|
+
if (at - Date.parse(item.createdAt) < OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS)
|
|
716
|
+
return false;
|
|
717
|
+
if (!plausiblyFulfils(item.text, message))
|
|
718
|
+
return false;
|
|
719
|
+
return !this.promisesAnew(item, message, at);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Whether a post makes a NEW promise: restating the one it delivers does not count.
|
|
723
|
+
*
|
|
724
|
+
* @param item - The commitment being delivered
|
|
725
|
+
* @param message - The post
|
|
726
|
+
* @param at - Its time (epoch ms)
|
|
727
|
+
* @returns True when it promises something else
|
|
728
|
+
*/
|
|
729
|
+
promisesAnew(item, message, at) {
|
|
730
|
+
return extractOpenItems(message.content, { now: new Date(at) }).commitments.some((c) => questionSimilarity(c.text, item.text) < OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* The agent that owns a follow-up WorkItem closes it as already delivered:
|
|
734
|
+
* the open item is closed and the WorkItem finished (no done_by_worker step).
|
|
735
|
+
*
|
|
736
|
+
* @param workItemId - The follow-up WorkItem
|
|
737
|
+
* @param agent - The session closing it (must be the promising agent)
|
|
738
|
+
* @param summary - What the agent says (where it was delivered)
|
|
739
|
+
* @returns True when an active item of that agent was closed
|
|
740
|
+
*/
|
|
741
|
+
async closeByAgent(workItemId, agent, summary) {
|
|
742
|
+
return this.serial(async () => {
|
|
743
|
+
const all = await this.deps.requests.listAll();
|
|
744
|
+
const request = all.find((r) => (r.openItems ?? []).some((i) => i.workItemId === workItemId));
|
|
745
|
+
if (!request)
|
|
746
|
+
return false;
|
|
747
|
+
const target = (request.openItems ?? []).find((i) => i.workItemId === workItemId);
|
|
748
|
+
if (!target || target.agent !== agent || !ACTIVE_OPEN_ITEM_STATUSES.has(target.status))
|
|
749
|
+
return false;
|
|
750
|
+
const at = this.now().toISOString();
|
|
751
|
+
const items = (request.openItems ?? []).map((i) => i.id === target.id ? { ...i, status: 'delivered', closedAt: at, closedReason: `closed by ${agent}: ${short(summary, 200)}` } : i);
|
|
752
|
+
await this.deps.closeFollowUp?.(workItemId, 'delivered', `Closed by ${agent}`);
|
|
753
|
+
await this.save(request, items, false);
|
|
754
|
+
return true;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Take planned items onto a request (the backfill's apply step).
|
|
759
|
+
*
|
|
760
|
+
* @param requestId - Request
|
|
761
|
+
* @param planned - Items from {@link plan}
|
|
762
|
+
* @param opts - `source: 'backfill'` marks the cards it posts as backfilled; `caller` is
|
|
763
|
+
* the session of whoever asked for it (logged: who ran an apply)
|
|
764
|
+
* @returns Updated request, or null when it is gone
|
|
765
|
+
*/
|
|
766
|
+
async adopt(requestId, planned, opts = {}) {
|
|
767
|
+
return this.serial(async () => {
|
|
768
|
+
this.logger.info('Open items adopted (backfill apply)', { requestId, count: planned.length, caller: opts.caller ?? 'unknown', source: opts.source ?? 'live' });
|
|
769
|
+
const request = await this.deps.requests.getById(requestId);
|
|
770
|
+
if (!request)
|
|
771
|
+
return null;
|
|
772
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
773
|
+
const items = [...(request.openItems ?? [])];
|
|
774
|
+
for (const p of planned) {
|
|
775
|
+
if (items.some((i) => i.id === p.item.id))
|
|
776
|
+
continue;
|
|
777
|
+
items.push(await this.activate(request, p, pool, opts.source ?? 'live'));
|
|
778
|
+
}
|
|
779
|
+
return this.save(request, items, true);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Store items, and move the request between `done` and `awaiting_followup`.
|
|
784
|
+
*
|
|
785
|
+
* @param request - Request as read
|
|
786
|
+
* @param items - New item list
|
|
787
|
+
* @param added - New active items were added
|
|
788
|
+
* @returns Updated request
|
|
789
|
+
*/
|
|
790
|
+
async save(request, items, added) {
|
|
791
|
+
const active = items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
|
|
792
|
+
if (active && request.status === 'done' && added) {
|
|
793
|
+
this.logger.info('Closed ticket reopened: its agent left open items in the thread', { tkt: ticketLabel(request) });
|
|
794
|
+
return this.deps.requests.update(request.id, { openItems: items, status: 'awaiting_followup', reopenForFollowup: true });
|
|
795
|
+
}
|
|
796
|
+
if (!active && request.status === 'awaiting_followup') {
|
|
797
|
+
this.logger.info('Every open item closed — ticket done', { tkt: ticketLabel(request) });
|
|
798
|
+
return this.deps.requests.update(request.id, { openItems: items, status: 'done', accepted: true, ignoreDeadChildren: true });
|
|
799
|
+
}
|
|
800
|
+
return this.deps.requests.update(request.id, { openItems: items });
|
|
801
|
+
}
|
|
802
|
+
// -------------------------------------------------------------------------
|
|
803
|
+
// Child work
|
|
804
|
+
// -------------------------------------------------------------------------
|
|
805
|
+
/**
|
|
806
|
+
* A WorkItem finished (task:done / task:verified): wake whoever promised
|
|
807
|
+
* the owner the result, if that was the last piece.
|
|
808
|
+
*
|
|
809
|
+
* @param workItemId - The WorkItem
|
|
810
|
+
* @returns Items that became ready
|
|
811
|
+
*/
|
|
812
|
+
async onWorkItemSettled(workItemId) {
|
|
813
|
+
return this.serial(async () => {
|
|
814
|
+
const woken = [];
|
|
815
|
+
const all = await this.deps.requests.listAll();
|
|
816
|
+
const affected = all.filter((r) => (r.openItems ?? []).some((i) => i.type === 'commitment' && i.status === 'open' && (i.childWorkItemIds ?? []).includes(workItemId)));
|
|
817
|
+
if (affected.length === 0)
|
|
818
|
+
return woken;
|
|
819
|
+
const pool = await this.deps.listWorkItems();
|
|
820
|
+
for (const request of affected) {
|
|
821
|
+
let changed = false;
|
|
822
|
+
const items = [];
|
|
823
|
+
for (const item of request.openItems ?? []) {
|
|
824
|
+
if (item.type === 'commitment' && item.status === 'open' && (item.childWorkItemIds ?? []).includes(workItemId)) {
|
|
825
|
+
const state = childrenState(item.childWorkItemIds ?? [], pool);
|
|
826
|
+
if (state.ready) {
|
|
827
|
+
const ready = await this.markReady(request, item, state.finishedAt, state.done);
|
|
828
|
+
items.push(ready);
|
|
829
|
+
woken.push(ready);
|
|
830
|
+
changed = true;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
items.push(item);
|
|
835
|
+
}
|
|
836
|
+
if (changed)
|
|
837
|
+
await this.deps.requests.update(request.id, { openItems: items });
|
|
838
|
+
}
|
|
839
|
+
return woken;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* The child work is finished: wake the agent to deliver.
|
|
844
|
+
*
|
|
845
|
+
* @param request - Request
|
|
846
|
+
* @param item - Commitment
|
|
847
|
+
* @param finishedAt - When the last child finished
|
|
848
|
+
* @param done - Finished children
|
|
849
|
+
* @returns Updated item (`ready`)
|
|
850
|
+
*/
|
|
851
|
+
async markReady(request, item, finishedAt, done) {
|
|
852
|
+
const now = this.now();
|
|
853
|
+
const what = done.map((w) => `"${short(w.title, 80)}"`).join(', ');
|
|
854
|
+
const ref = followUpReference(request, item);
|
|
855
|
+
const text = `[FOLLOW-UP ${ticketLabel(request)}] The work you promised the owner is ready (${what}) — deliver it now. ` +
|
|
856
|
+
`You said: "${short(item.text, 200)}". Run: ${followUpCommand(ref)} — Crewly posts it in the ticket's thread; that closes the follow-up.`;
|
|
857
|
+
const ok = await this.deps.deliverToAgent(item.agent, text).catch(() => false);
|
|
858
|
+
if (ok)
|
|
859
|
+
AgentPromptReferenceService.getInstance().note(item.agent, ref, `[FOLLOW-UP ${ticketLabel(request)}]`);
|
|
860
|
+
this.logger.info('Promised work is ready — agent woken to deliver', { tkt: ticketLabel(request), item: item.id, agent: item.agent, delivered: ok });
|
|
861
|
+
return { ...item, status: 'ready', readyAt: finishedAt ?? now.toISOString(), ...(ok ? { wokeAt: now.toISOString() } : {}) };
|
|
862
|
+
}
|
|
863
|
+
// -------------------------------------------------------------------------
|
|
864
|
+
// Decisions
|
|
865
|
+
// -------------------------------------------------------------------------
|
|
866
|
+
/**
|
|
867
|
+
* A `reply_question` decision settled: close its open item and give the
|
|
868
|
+
* agent the answer (the generic decision note).
|
|
869
|
+
*
|
|
870
|
+
* @param d - The decision
|
|
871
|
+
* @param fallback - The generic note
|
|
872
|
+
* @returns The note for the agent (null when withdrawn)
|
|
873
|
+
*/
|
|
874
|
+
async onDecisionSettled(d, fallback) {
|
|
875
|
+
const ref = d.requestRef;
|
|
876
|
+
if (!ref)
|
|
877
|
+
return fallback ?? null;
|
|
878
|
+
// Not awaited: the handler can run inside this service's own chain (the
|
|
879
|
+
// sweep withdrawing a card), and the note does not depend on the write.
|
|
880
|
+
void this.serial(async () => {
|
|
881
|
+
const request = await this.deps.requests.getById(ref.requestId);
|
|
882
|
+
if (!request)
|
|
883
|
+
return;
|
|
884
|
+
const now = this.now().toISOString();
|
|
885
|
+
const status = itemStatusFor(d);
|
|
886
|
+
if (status === 'open')
|
|
887
|
+
return; // parked: still waiting on the owner
|
|
888
|
+
const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
|
|
889
|
+
const items = (request.openItems ?? []).map((i) => i.id === ref.itemId && ACTIVE_OPEN_ITEM_STATUSES.has(i.status)
|
|
890
|
+
? { ...i, status, closedAt: now, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) }
|
|
891
|
+
: i);
|
|
892
|
+
await this.save(request, items, false);
|
|
893
|
+
}).catch((err) => this.logger.warn('Answered question not recorded', { decisionId: d.id, error: errText(err) }));
|
|
894
|
+
if (d.status === 'cancelled')
|
|
895
|
+
return null;
|
|
896
|
+
const reply = d.chosenKey && d.options.find((o) => o.key === d.chosenKey)?.label === OPEN_ITEMS_CONSTANTS.REPLY_LABEL;
|
|
897
|
+
if (reply) {
|
|
898
|
+
return `[DECISION ${d.id}] The owner will answer "${d.question}" in words in the thread. Wait for their message there, then act on it.`;
|
|
899
|
+
}
|
|
900
|
+
return fallback ?? null;
|
|
901
|
+
}
|
|
902
|
+
// -------------------------------------------------------------------------
|
|
903
|
+
// Owner skips
|
|
904
|
+
// -------------------------------------------------------------------------
|
|
905
|
+
/**
|
|
906
|
+
* The owner skipped an open item ("I don't care about this anymore"). A
|
|
907
|
+
* promise is closed, its follow-up WorkItem cancelled and the agent told
|
|
908
|
+
* once to drop it; a question's card is skipped (which closes the item and
|
|
909
|
+
* tells the agent). The request completes when nothing else is open.
|
|
910
|
+
*
|
|
911
|
+
* @param requestId - Request
|
|
912
|
+
* @param itemId - Open item
|
|
913
|
+
* @returns The closed item
|
|
914
|
+
* @throws OpenItemsError(404 / 409)
|
|
915
|
+
*/
|
|
916
|
+
async skipItem(requestId, itemId) {
|
|
917
|
+
const request = await this.deps.requests.getById(requestId);
|
|
918
|
+
if (!request)
|
|
919
|
+
throw new OpenItemsError(404, `Request ${requestId} not found`);
|
|
920
|
+
const item = (request.openItems ?? []).find((i) => i.id === itemId);
|
|
921
|
+
if (!item)
|
|
922
|
+
throw new OpenItemsError(404, `Open item ${itemId} not found on ${ticketLabel(request)}`);
|
|
923
|
+
if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
|
|
924
|
+
throw new OpenItemsError(409, `Open item ${itemId} is already ${item.status}`);
|
|
925
|
+
// A question with a pending card: skip the card; its settle handler closes the item.
|
|
926
|
+
if (item.type === 'question' && item.decisionId && this.deps.skipQuestion) {
|
|
927
|
+
await this.deps.skipQuestion(item.decisionId).catch(() => false);
|
|
928
|
+
}
|
|
929
|
+
let note = null;
|
|
930
|
+
const closed = await this.serial(async () => {
|
|
931
|
+
const fresh = await this.deps.requests.getById(requestId);
|
|
932
|
+
const cur = fresh?.openItems?.find((i) => i.id === itemId);
|
|
933
|
+
if (!fresh || !cur)
|
|
934
|
+
throw new OpenItemsError(404, `Open item ${itemId} not found`);
|
|
935
|
+
if (!ACTIVE_OPEN_ITEM_STATUSES.has(cur.status))
|
|
936
|
+
return cur;
|
|
937
|
+
const next = { ...cur, status: 'skipped', closedAt: this.now().toISOString(), closedReason: 'skipped by the owner' };
|
|
938
|
+
if (cur.type === 'commitment') {
|
|
939
|
+
if (cur.workItemId)
|
|
940
|
+
await this.closeFollowUp(cur.workItemId, 'cancelled', 'The owner skipped this promise');
|
|
941
|
+
note =
|
|
942
|
+
`[FOLLOW-UP ${ticketLabel(fresh)}] The owner skipped this — drop it, don't ask again. ` +
|
|
943
|
+
`You had promised: "${short(cur.text, 200)}". Don't deliver it unless the owner asks again.`;
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
note = `[FOLLOW-UP ${ticketLabel(fresh)}] The owner skipped this — drop it, don't ask again: "${short(cur.text, 200)}".`;
|
|
947
|
+
}
|
|
948
|
+
const items = (fresh.openItems ?? []).map((i) => (i.id === itemId ? next : i));
|
|
949
|
+
await this.save(fresh, items, false);
|
|
950
|
+
return next;
|
|
951
|
+
});
|
|
952
|
+
if (note)
|
|
953
|
+
await this.deps.deliverToAgent(closed.agent, note).catch(() => false);
|
|
954
|
+
this.logger.info('Open item skipped by the owner', { tkt: ticketLabel(request), item: itemId, type: closed.type });
|
|
955
|
+
return closed;
|
|
956
|
+
}
|
|
957
|
+
// -------------------------------------------------------------------------
|
|
958
|
+
// Sweep
|
|
959
|
+
// -------------------------------------------------------------------------
|
|
960
|
+
/**
|
|
961
|
+
* Periodic pass: link late child work, catch missed readiness, nudge
|
|
962
|
+
* overdue promises once and then tell the owner, sync answered questions,
|
|
963
|
+
* expire stale items, close cancelled tickets' items.
|
|
964
|
+
*
|
|
965
|
+
* @returns Counts of what happened
|
|
966
|
+
*/
|
|
967
|
+
async sweep() {
|
|
968
|
+
return this.serial(async () => {
|
|
969
|
+
const counts = { ready: 0, nudged: 0, ownerNotes: 0, expired: 0, closed: 0 };
|
|
970
|
+
const now = this.now();
|
|
971
|
+
const all = await this.deps.requests.listAll();
|
|
972
|
+
const tracked = all.filter((r) => r.status === 'awaiting_followup' || (r.openItems ?? []).some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status)));
|
|
973
|
+
if (tracked.length === 0)
|
|
974
|
+
return counts;
|
|
975
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
976
|
+
for (const request of tracked) {
|
|
977
|
+
try {
|
|
978
|
+
const before = JSON.stringify(request.openItems);
|
|
979
|
+
const items = [];
|
|
980
|
+
for (const item of request.openItems ?? [])
|
|
981
|
+
items.push(await this.sweepItem(request, item, now, pool, counts));
|
|
982
|
+
const changed = JSON.stringify(items) !== before;
|
|
983
|
+
const allClosed = !items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
|
|
984
|
+
if (changed || (request.status === 'awaiting_followup' && allClosed))
|
|
985
|
+
await this.save(request, items, false);
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
this.logger.warn('Open-items sweep step failed', { id: request.id, error: errText(err) });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (counts.ready + counts.nudged + counts.ownerNotes + counts.expired + counts.closed > 0) {
|
|
992
|
+
this.logger.info('Open-items sweep', counts);
|
|
993
|
+
}
|
|
994
|
+
return counts;
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* One item's sweep step.
|
|
999
|
+
*/
|
|
1000
|
+
async sweepItem(request, item, now, pool, counts) {
|
|
1001
|
+
if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
|
|
1002
|
+
return item;
|
|
1003
|
+
const nowIso = now.toISOString();
|
|
1004
|
+
if (request.status === 'cancelled') {
|
|
1005
|
+
counts.closed += 1;
|
|
1006
|
+
if (item.workItemId)
|
|
1007
|
+
await this.closeFollowUp(item.workItemId, 'cancelled', 'The ticket was cancelled');
|
|
1008
|
+
if (item.decisionId)
|
|
1009
|
+
await this.deps.cancelQuestion?.(item.decisionId, 'ticket cancelled').catch(() => undefined);
|
|
1010
|
+
return { ...item, status: 'cancelled', closedAt: nowIso, closedReason: 'ticket cancelled' };
|
|
1011
|
+
}
|
|
1012
|
+
if (now.getTime() - Date.parse(item.createdAt) >= OPEN_ITEMS_CONSTANTS.EXPIRE_AFTER_MS) {
|
|
1013
|
+
counts.expired += 1;
|
|
1014
|
+
if (item.workItemId)
|
|
1015
|
+
await this.closeFollowUp(item.workItemId, 'cancelled', 'Expired: nothing happened for 7 days');
|
|
1016
|
+
return { ...item, status: 'expired', closedAt: nowIso, closedReason: 'no activity for 7 days' };
|
|
1017
|
+
}
|
|
1018
|
+
if (item.type === 'question') {
|
|
1019
|
+
if (!item.decisionId || !this.deps.getDecision)
|
|
1020
|
+
return item;
|
|
1021
|
+
const d = await this.deps.getDecision(item.decisionId).catch(() => null);
|
|
1022
|
+
if (!d || d.status === 'open' || d.status === 'parked')
|
|
1023
|
+
return item;
|
|
1024
|
+
const status = itemStatusFor(d);
|
|
1025
|
+
const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
|
|
1026
|
+
counts.closed += 1;
|
|
1027
|
+
return { ...item, status, closedAt: nowIso, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) };
|
|
1028
|
+
}
|
|
1029
|
+
if (item.status === 'waiting_owner') {
|
|
1030
|
+
// No due time, no nudges. A card answer opens it; a declined card closes it.
|
|
1031
|
+
if (!item.gateDecisionId || !this.deps.getDecision)
|
|
1032
|
+
return item;
|
|
1033
|
+
const d = await this.deps.getDecision(item.gateDecisionId).catch(() => null);
|
|
1034
|
+
if (!d || d.status === 'open' || d.status === 'parked')
|
|
1035
|
+
return item;
|
|
1036
|
+
if (d.status === 'cancelled' || d.status === 'expired' || (d.chosenKey && d.yesKey && d.chosenKey !== d.yesKey)) {
|
|
1037
|
+
counts.closed += 1;
|
|
1038
|
+
return { ...item, status: 'superseded', closedAt: nowIso, closedReason: `${d.id} ${d.status}: the owner did not agree` };
|
|
1039
|
+
}
|
|
1040
|
+
const at = d.resolvedAt ? new Date(d.resolvedAt) : now;
|
|
1041
|
+
const { due, source } = parseDue(item.text, at);
|
|
1042
|
+
return this.activate(request, { item: { ...item, status: 'open', due: due.toISOString(), dueSource: source } }, pool);
|
|
1043
|
+
}
|
|
1044
|
+
// Commitment whose follow-up WorkItem was finished or cancelled (by anyone,
|
|
1045
|
+
// through the task API): the item is closed with it, so it cannot nudge or be re-armed.
|
|
1046
|
+
const followUp = item.workItemId ? pool.find((w) => w.id === item.workItemId) : undefined;
|
|
1047
|
+
if (followUp && ['done', 'verified', 'cancelled'].includes(followUp.status)) {
|
|
1048
|
+
counts.closed += 1;
|
|
1049
|
+
const delivered = followUp.status !== 'cancelled';
|
|
1050
|
+
return {
|
|
1051
|
+
...item,
|
|
1052
|
+
status: delivered ? 'delivered' : 'cancelled',
|
|
1053
|
+
closedAt: nowIso,
|
|
1054
|
+
closedReason: `follow-up ${followUp.id.slice(0, 8)} is ${followUp.status}`,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
// Commitment: link child work started after the promise.
|
|
1058
|
+
let current = item;
|
|
1059
|
+
const children = childWorkFor(request, item, pool);
|
|
1060
|
+
if (children.some((id) => !(item.childWorkItemIds ?? []).includes(id))) {
|
|
1061
|
+
current = { ...current, childWorkItemIds: [...new Set([...(item.childWorkItemIds ?? []), ...children])] };
|
|
1062
|
+
}
|
|
1063
|
+
if (current.status === 'open' && (current.childWorkItemIds ?? []).length > 0) {
|
|
1064
|
+
const state = childrenState(current.childWorkItemIds ?? [], pool);
|
|
1065
|
+
if (state.ready) {
|
|
1066
|
+
counts.ready += 1;
|
|
1067
|
+
return this.markReady(request, current, state.finishedAt, state.done);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const due = current.due ? Date.parse(current.due) : NaN;
|
|
1071
|
+
if (!Number.isFinite(due) || now.getTime() < due)
|
|
1072
|
+
return current;
|
|
1073
|
+
if (!current.nudgedAt) {
|
|
1074
|
+
const ref = followUpReference(request, current);
|
|
1075
|
+
const text = `[FOLLOW-UP ${ticketLabel(request)}] You promised the owner: "${short(current.text, 200)}" — due ${formatWhen(new Date(due), now)}, and it hasn't been delivered. ` +
|
|
1076
|
+
`Deliver it now, or tell the owner plainly when it will come and why. Run: ${followUpCommand(ref)} — Crewly posts it in the ticket's thread.`;
|
|
1077
|
+
const ok = await this.deps.deliverToAgent(current.agent, text).catch(() => false);
|
|
1078
|
+
if (ok)
|
|
1079
|
+
AgentPromptReferenceService.getInstance().note(current.agent, ref, `[FOLLOW-UP ${ticketLabel(request)}]`);
|
|
1080
|
+
counts.nudged += 1;
|
|
1081
|
+
this.logger.info('Overdue promise — agent nudged', { tkt: ticketLabel(request), item: current.id, agent: current.agent, delivered: ok });
|
|
1082
|
+
return { ...current, status: 'overdue', nudgedAt: nowIso };
|
|
1083
|
+
}
|
|
1084
|
+
if (!current.ownerNotifiedAt && now.getTime() - Date.parse(current.nudgedAt) >= OPEN_ITEMS_CONSTANTS.OWNER_NOTE_AFTER_NUDGE_MS) {
|
|
1085
|
+
const note = await this.ownerNote(request, current, pool, now);
|
|
1086
|
+
const ok = await this.deps.postOwnerNote(request, note).catch(() => false);
|
|
1087
|
+
counts.ownerNotes += 1;
|
|
1088
|
+
this.logger.info('Overdue promise — owner told in the thread', { tkt: ticketLabel(request), item: current.id, posted: ok });
|
|
1089
|
+
return ok ? { ...current, status: 'overdue', ownerNotifiedAt: nowIso } : current;
|
|
1090
|
+
}
|
|
1091
|
+
return current;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* The owner's note for a late promise: what, by when, and why it is late.
|
|
1095
|
+
*
|
|
1096
|
+
* @param request - Request
|
|
1097
|
+
* @param item - Commitment
|
|
1098
|
+
* @param pool - Every WorkItem
|
|
1099
|
+
* @param now - Clock
|
|
1100
|
+
* @returns Note text (English harness text; the agent's words quoted)
|
|
1101
|
+
*/
|
|
1102
|
+
async ownerNote(request, item, pool, now) {
|
|
1103
|
+
const name = (await this.deps.displayName?.(item.agent).catch(() => undefined)) ?? item.agent;
|
|
1104
|
+
const due = item.due ? formatWhen(new Date(item.due), now) : 'today';
|
|
1105
|
+
const state = childrenState(item.childWorkItemIds ?? [], pool);
|
|
1106
|
+
let why;
|
|
1107
|
+
if (state.pending.length > 0) {
|
|
1108
|
+
const parts = [];
|
|
1109
|
+
for (const w of state.pending.slice(0, 2)) {
|
|
1110
|
+
const who = w.target ? ((await this.deps.displayName?.(w.target).catch(() => undefined)) ?? w.target) : 'nobody';
|
|
1111
|
+
parts.push(`${who}'s part ("${short(w.title, 60)}") is still ${w.status.replace(/_/g, ' ')}`);
|
|
1112
|
+
}
|
|
1113
|
+
why = parts.join('; ');
|
|
1114
|
+
}
|
|
1115
|
+
else if (state.ready || item.readyAt) {
|
|
1116
|
+
const at = item.readyAt ? formatWhen(new Date(item.readyAt), now) : 'earlier';
|
|
1117
|
+
why = `the work was ready at ${at}, but ${name} hasn't posted it`;
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
why = `${name} hasn't posted it yet`;
|
|
1121
|
+
}
|
|
1122
|
+
return `${name} promised "${short(item.text, 160)}" by ${due}. It hasn't arrived: ${why}. ${name} has been reminded.`;
|
|
1123
|
+
}
|
|
1124
|
+
async closeFollowUp(workItemId, outcome, reason) {
|
|
1125
|
+
await this.deps.closeFollowUp?.(workItemId, outcome, reason).catch((err) => this.logger.debug('Follow-up WorkItem not closed', { workItemId, error: errText(err) }));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The open-item status a settled decision leaves its question in.
|
|
1130
|
+
*
|
|
1131
|
+
* @param d - Decision
|
|
1132
|
+
* @returns Status (`open` while it is still pending)
|
|
1133
|
+
*/
|
|
1134
|
+
export function itemStatusFor(d) {
|
|
1135
|
+
switch (d.status) {
|
|
1136
|
+
case 'resolved':
|
|
1137
|
+
case 'defaulted':
|
|
1138
|
+
return 'resolved';
|
|
1139
|
+
case 'cancelled':
|
|
1140
|
+
return 'superseded';
|
|
1141
|
+
case 'expired':
|
|
1142
|
+
return 'expired';
|
|
1143
|
+
case 'skipped':
|
|
1144
|
+
return 'skipped';
|
|
1145
|
+
default:
|
|
1146
|
+
return 'open';
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Message of an unknown error.
|
|
1151
|
+
*
|
|
1152
|
+
* @param err - Thrown value
|
|
1153
|
+
* @returns Text
|
|
1154
|
+
*/
|
|
1155
|
+
function errText(err) {
|
|
1156
|
+
return err instanceof Error ? err.message : String(err);
|
|
1157
|
+
}
|
|
1158
|
+
//# sourceMappingURL=open-items.service.js.map
|