crewly 1.20.154 → 1.20.155
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/team-leader/delegate-task/execute.sh +17 -4
- package/dist/backend/backend/src/constants.d.ts +38 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +38 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts +32 -0
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +122 -8
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +13 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts +15 -0
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +36 -0
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.d.ts +153 -1
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.js +371 -22
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.js.map +1 -1
- package/dist/backend/backend/src/services/event-bus/event-bus.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/event-bus/event-bus.service.js +6 -0
- package/dist/backend/backend/src/services/event-bus/event-bus.service.js.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js +2 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts +47 -0
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js +118 -3
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js.map +1 -1
- package/dist/backend/backend/src/types/event-bus.types.d.ts +5 -0
- package/dist/backend/backend/src/types/event-bus.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/event-bus.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +38 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +38 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts +15 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +36 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.d.ts +409 -0
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.js +961 -0
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.js.map +1 -0
- package/dist/cli/backend/src/services/event-bus/event-bus.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/event-bus/event-bus.service.js +6 -0
- package/dist/cli/backend/src/services/event-bus/event-bus.service.js.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js +2 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/cli/backend/src/types/event-bus.types.d.ts +5 -0
- package/dist/cli/backend/src/types/event-bus.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/event-bus.types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fresh Task Conversation Service
|
|
3
|
+
*
|
|
4
|
+
* Claude Code agents keep one conversation forever, so a small new task
|
|
5
|
+
* re-reads hundreds of thousands of tokens of unrelated history on every
|
|
6
|
+
* turn (81% of measured usage came from turns over 300k context). This
|
|
7
|
+
* service starts a fresh conversation when a team member is handed a task
|
|
8
|
+
* whose ROOT differs from the last one it was given:
|
|
9
|
+
*
|
|
10
|
+
* 1. The old conversation is saved server-side — a handover file under
|
|
11
|
+
* `<CREWLY_HOME>/handover/` (same format the orchestrator uses at
|
|
12
|
+
* restart) and a concise copy through the memory `remember` path, which
|
|
13
|
+
* mirrors into the wiki. No agent turn is spent on it.
|
|
14
|
+
* 2. `/clear` is written to the PTY the same way context compaction writes
|
|
15
|
+
* its command (Escape, 200 ms, command + CR), then a short pause for the
|
|
16
|
+
* prompt to come back.
|
|
17
|
+
* 3. The dispatch goes on with a one-line note pointing at the handover.
|
|
18
|
+
* 4. In the background the new transcript Claude Code starts is found and
|
|
19
|
+
* its id stored, so a restart resumes the NEW conversation; if it cannot
|
|
20
|
+
* be found the stored id is cleared so a restart starts fresh instead of
|
|
21
|
+
* resuming the huge pre-clear one.
|
|
22
|
+
*
|
|
23
|
+
* Retries, verifications and reviews of the same task share its root
|
|
24
|
+
* (`<id>:retry:N`, `<id>:verify:<id>`, `<id>:review:…`), so they keep their
|
|
25
|
+
* context. Never the orchestrator, only Claude Code, only when the agent is
|
|
26
|
+
* idle and nobody else just wrote to it; any failure falls back to plain
|
|
27
|
+
* delivery — nothing here throws into dispatch.
|
|
28
|
+
*
|
|
29
|
+
* Idle-boundary context cap: one task can run for 1000+ turns, so a new root
|
|
30
|
+
* alone does not bound the context. A periodic sweep
|
|
31
|
+
* ({@link FreshTaskConversationService.startContextCapSweep}) saves and
|
|
32
|
+
* clears an idle Claude Code member whose last turn carried more than
|
|
33
|
+
* `CREWLY_MEMBER_CONTEXT_CAP_TOKENS` (300k by default, `0` disables), then
|
|
34
|
+
* writes one line naming its active WorkItem and the handover. Same safety
|
|
35
|
+
* rules — never the orchestrator, never mid-turn, never while a message is
|
|
36
|
+
* queued for or being delivered to it — plus at most one cap per 20 minutes.
|
|
37
|
+
*
|
|
38
|
+
* @module services/agent/fresh-task-conversation.service
|
|
39
|
+
*/
|
|
40
|
+
import * as fs from 'fs';
|
|
41
|
+
import * as path from 'path';
|
|
42
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
43
|
+
import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
|
|
44
|
+
import { CREWLY_CONSTANTS, FRESH_TASK_CONVERSATION_CONSTANTS, ORC_CONVERSATION_CONSTANTS, ORCHESTRATOR_SESSION_NAME, RUNTIME_TYPES, } from '../../constants.js';
|
|
45
|
+
import { buildHandoverSummary, claudeTranscriptPath, lastTurnContextTokens } from './runtime-session-recovery.js';
|
|
46
|
+
import { getSessionStatePersistence } from '../session/session-state-persistence.js';
|
|
47
|
+
import { getSessionBackendSync } from '../session/session-backend.factory.js';
|
|
48
|
+
import { PtyActivityTrackerService } from './pty-activity-tracker.service.js';
|
|
49
|
+
import { SubAgentMessageQueue } from '../messaging/sub-agent-message-queue.service.js';
|
|
50
|
+
import { STANDING_ANSWERS_CONSTANTS } from '../../constants.js';
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Pure helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* The root task id of a work item: its id with any follow-up suffix
|
|
56
|
+
* (`:retry:N`, `:verify:…`, `:review:…`) removed, so every follow-up of one
|
|
57
|
+
* task maps to the same root.
|
|
58
|
+
*
|
|
59
|
+
* @param workItemId - WorkItem id
|
|
60
|
+
* @returns The root id
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* rootWorkItemId('abc:retry:2'); // 'abc'
|
|
65
|
+
* rootWorkItemId('abc:verify:abc'); // 'abc'
|
|
66
|
+
* rootWorkItemId('abc:retry:1:retry:2'); // 'abc'
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function rootWorkItemId(workItemId) {
|
|
70
|
+
let cut = workItemId.length;
|
|
71
|
+
for (const marker of FRESH_TASK_CONVERSATION_CONSTANTS.ROOT_SUFFIX_MARKERS) {
|
|
72
|
+
const i = workItemId.indexOf(marker);
|
|
73
|
+
if (i > 0 && i < cut)
|
|
74
|
+
cut = i;
|
|
75
|
+
}
|
|
76
|
+
return workItemId.slice(0, cut);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether the feature is switched on by the environment.
|
|
80
|
+
*
|
|
81
|
+
* @param env - Environment (tests)
|
|
82
|
+
* @returns False when CREWLY_FRESH_TASK_CONVERSATION is 0 / false / off / no
|
|
83
|
+
*/
|
|
84
|
+
export function freshTaskConversationEnvEnabled(env = process.env) {
|
|
85
|
+
const raw = env[FRESH_TASK_CONVERSATION_CONSTANTS.ENV_TOGGLE];
|
|
86
|
+
if (raw === undefined)
|
|
87
|
+
return true;
|
|
88
|
+
return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase());
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Decide whether to start a fresh conversation before delivering a task.
|
|
92
|
+
*
|
|
93
|
+
* @param input - Decision inputs
|
|
94
|
+
* @returns The decision
|
|
95
|
+
*/
|
|
96
|
+
export function decideFreshConversation(input) {
|
|
97
|
+
if (input.sessionName === ORCHESTRATOR_SESSION_NAME)
|
|
98
|
+
return { clear: false, reason: 'orchestrator' };
|
|
99
|
+
if (input.runtimeType !== RUNTIME_TYPES.CLAUDE_CODE)
|
|
100
|
+
return { clear: false, reason: 'not claude-code' };
|
|
101
|
+
if (!input.enabled)
|
|
102
|
+
return { clear: false, reason: 'disabled' };
|
|
103
|
+
if (!input.previousRoot)
|
|
104
|
+
return { clear: false, reason: 'first task' };
|
|
105
|
+
if (input.previousRoot === input.newRoot)
|
|
106
|
+
return { clear: false, reason: 'same task' };
|
|
107
|
+
if (input.busy)
|
|
108
|
+
return { clear: false, reason: 'busy' };
|
|
109
|
+
if (input.recentDelivery)
|
|
110
|
+
return { clear: false, reason: 'recent delivery' };
|
|
111
|
+
if (input.alreadyStarted)
|
|
112
|
+
return { clear: false, reason: 'already working on it' };
|
|
113
|
+
if (input.otherActiveRoots.some((r) => r !== input.newRoot))
|
|
114
|
+
return { clear: false, reason: 'other work in progress' };
|
|
115
|
+
return { clear: true, reason: 'new task' };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The line put in front of the task text after a fresh start.
|
|
119
|
+
*
|
|
120
|
+
* @param handoverPath - Handover file written for the old conversation
|
|
121
|
+
* @returns One line
|
|
122
|
+
*/
|
|
123
|
+
export function freshConversationNote(handoverPath) {
|
|
124
|
+
return `Fresh conversation for this task — your earlier work is in ${handoverPath} and your wiki; read them only if this task needs it.`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The context cap for members, from the environment.
|
|
128
|
+
*
|
|
129
|
+
* @param env - Environment (tests)
|
|
130
|
+
* @returns Tokens; 0 means the cap is off. Unset or invalid → the default.
|
|
131
|
+
*/
|
|
132
|
+
export function memberContextCapTokens(env = process.env) {
|
|
133
|
+
const raw = env[FRESH_TASK_CONVERSATION_CONSTANTS.MEMBER_CONTEXT_CAP_ENV];
|
|
134
|
+
if (raw === undefined || raw.trim() === '')
|
|
135
|
+
return FRESH_TASK_CONVERSATION_CONSTANTS.MEMBER_CONTEXT_CAP_TOKENS;
|
|
136
|
+
const n = Number(raw);
|
|
137
|
+
if (!Number.isFinite(n) || n < 0)
|
|
138
|
+
return FRESH_TASK_CONVERSATION_CONSTANTS.MEMBER_CONTEXT_CAP_TOKENS;
|
|
139
|
+
return Math.floor(n);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Decide whether to cap (save + clear + re-orient) a member's conversation.
|
|
143
|
+
* Pure; the order of checks is also the order of the logged reasons.
|
|
144
|
+
*
|
|
145
|
+
* @param input - Decision inputs
|
|
146
|
+
* @returns The decision
|
|
147
|
+
*/
|
|
148
|
+
export function decideContextCap(input) {
|
|
149
|
+
if (input.sessionName === ORCHESTRATOR_SESSION_NAME)
|
|
150
|
+
return { clear: false, reason: 'orchestrator' };
|
|
151
|
+
if (input.runtimeType !== RUNTIME_TYPES.CLAUDE_CODE)
|
|
152
|
+
return { clear: false, reason: 'not claude-code' };
|
|
153
|
+
if (input.capTokens <= 0)
|
|
154
|
+
return { clear: false, reason: 'cap off' };
|
|
155
|
+
if (!input.enabled)
|
|
156
|
+
return { clear: false, reason: 'disabled' };
|
|
157
|
+
if (input.contextTokens === null || input.contextTokens <= input.capTokens)
|
|
158
|
+
return { clear: false, reason: 'under cap' };
|
|
159
|
+
if (input.lastCapAt !== null &&
|
|
160
|
+
input.now - input.lastCapAt < FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_MIN_INTERVAL_MS) {
|
|
161
|
+
return { clear: false, reason: 'rate limited' };
|
|
162
|
+
}
|
|
163
|
+
if (input.busy)
|
|
164
|
+
return { clear: false, reason: 'busy' };
|
|
165
|
+
if (input.quietMs === null || input.quietMs < FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_MIN_QUIET_MS) {
|
|
166
|
+
return { clear: false, reason: 'not quiet long enough' };
|
|
167
|
+
}
|
|
168
|
+
if (input.deliveryActive)
|
|
169
|
+
return { clear: false, reason: 'delivery in progress' };
|
|
170
|
+
if (input.queuedMessages)
|
|
171
|
+
return { clear: false, reason: 'messages queued' };
|
|
172
|
+
// The new conversation is found by the WorkItem id in the re-orientation
|
|
173
|
+
// line; with no WorkItem there is nothing safe to track it by, and the
|
|
174
|
+
// next task's own prepare will start a fresh conversation anyway.
|
|
175
|
+
if (!input.activeWorkItemId)
|
|
176
|
+
return { clear: false, reason: 'no active work item' };
|
|
177
|
+
return { clear: true, reason: 'context over cap' };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The one line written after a context-cap clear. It must carry the WorkItem
|
|
181
|
+
* id: the new conversation id is found by it.
|
|
182
|
+
*
|
|
183
|
+
* @param args - WorkItem, handover file and the old context size
|
|
184
|
+
* @returns One line
|
|
185
|
+
*/
|
|
186
|
+
export function contextCapReorientation(args) {
|
|
187
|
+
const title = args.workItem.title.length > 80 ? `${args.workItem.title.slice(0, 77)}...` : args.workItem.title;
|
|
188
|
+
return (`${FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_TAG} Your conversation reached ${args.contextTokens.toLocaleString('en-US')} tokens, so it was saved and restarted. ` +
|
|
189
|
+
`You are on WorkItem ${args.workItem.id} ("${title}"). Your handover is in ${args.handoverPath} (also in your wiki) — read it, then continue that WorkItem where you left off.`);
|
|
190
|
+
}
|
|
191
|
+
/** Statuses that mean the agent is actively on a work item. */
|
|
192
|
+
const ACTIVE_STATUSES = new Set(['running', 'accepted', 'proposed']);
|
|
193
|
+
/**
|
|
194
|
+
* Default collaborators. Heavy services are imported lazily so importing this
|
|
195
|
+
* module (from the dispatch subscriber, the terminal controller) stays cheap.
|
|
196
|
+
*
|
|
197
|
+
* @returns Deps bound to the running backend
|
|
198
|
+
*/
|
|
199
|
+
function defaultDeps() {
|
|
200
|
+
const getPersistence = () => {
|
|
201
|
+
try {
|
|
202
|
+
return getSessionStatePersistence();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
getSessionInfo: (sessionName) => {
|
|
210
|
+
const meta = getPersistence()?.getSessionMetadata(sessionName);
|
|
211
|
+
if (!meta)
|
|
212
|
+
return null;
|
|
213
|
+
return { runtimeType: meta.runtimeType, cwd: meta.cwd, sessionId: meta.claudeSessionId };
|
|
214
|
+
},
|
|
215
|
+
getClaimedSessionIds: (sessionName) => {
|
|
216
|
+
const p = getPersistence();
|
|
217
|
+
const ids = new Set();
|
|
218
|
+
if (!p)
|
|
219
|
+
return ids;
|
|
220
|
+
for (const name of p.getRegisteredSessions()) {
|
|
221
|
+
const id = p.getSessionId(name);
|
|
222
|
+
if (id && name !== sessionName)
|
|
223
|
+
ids.add(id);
|
|
224
|
+
}
|
|
225
|
+
return ids;
|
|
226
|
+
},
|
|
227
|
+
updateSessionId: (sessionName, id) => getPersistence()?.updateSessionId(sessionName, id),
|
|
228
|
+
clearSessionId: (sessionName) => getPersistence()?.clearSessionId(sessionName),
|
|
229
|
+
isBusy: async (sessionName) => {
|
|
230
|
+
const { StorageService } = await import('../core/storage.service.js');
|
|
231
|
+
const info = await StorageService.getInstance().findMemberBySessionName(sessionName).catch(() => null);
|
|
232
|
+
if (info?.member.workingStatus === CREWLY_CONSTANTS.WORKING_STATUSES.IN_PROGRESS)
|
|
233
|
+
return true;
|
|
234
|
+
const tracker = PtyActivityTrackerService.getInstance();
|
|
235
|
+
return tracker.hasActivity(sessionName) && tracker.getIdleTimeMs(sessionName) < FRESH_TASK_CONVERSATION_CONSTANTS.MIN_QUIET_MS;
|
|
236
|
+
},
|
|
237
|
+
settingEnabled: async () => {
|
|
238
|
+
try {
|
|
239
|
+
const { getSettingsService } = await import('../settings/settings.service.js');
|
|
240
|
+
return (await getSettingsService().getSettings()).general.freshConversationPerTask !== false;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
getActiveItems: async (sessionName) => {
|
|
247
|
+
const { TaskPoolService } = await import('../task-pool/task-pool.service.js');
|
|
248
|
+
const all = await TaskPoolService.getInstance().getAllItems();
|
|
249
|
+
return all.filter((wi) => wi.target === sessionName && ACTIVE_STATUSES.has(wi.status));
|
|
250
|
+
},
|
|
251
|
+
writeToSession: (sessionName, data) => {
|
|
252
|
+
const session = getSessionBackendSync()?.getSession(sessionName);
|
|
253
|
+
if (!session)
|
|
254
|
+
return false;
|
|
255
|
+
session.write(data);
|
|
256
|
+
return true;
|
|
257
|
+
},
|
|
258
|
+
remember: async ({ agentId, projectPath, content, title }) => {
|
|
259
|
+
const { MemoryService } = await import('../memory/memory.service.js');
|
|
260
|
+
return MemoryService.getInstance().remember({
|
|
261
|
+
agentId,
|
|
262
|
+
projectPath,
|
|
263
|
+
content,
|
|
264
|
+
category: 'fact',
|
|
265
|
+
scope: 'agent',
|
|
266
|
+
metadata: { title },
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
crewlyHome: getCrewlyHomePath,
|
|
270
|
+
now: () => Date.now(),
|
|
271
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
272
|
+
env: process.env,
|
|
273
|
+
listSessions: () => getPersistence()?.getRegisteredSessions() ?? [],
|
|
274
|
+
hasQueuedMessages: (sessionName) => {
|
|
275
|
+
try {
|
|
276
|
+
return SubAgentMessageQueue.getInstance().hasPending(sessionName);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
getQuietMs: (sessionName) => {
|
|
283
|
+
const tracker = PtyActivityTrackerService.getInstance();
|
|
284
|
+
return tracker.hasActivity(sessionName) ? tracker.getIdleTimeMs(sessionName) : null;
|
|
285
|
+
},
|
|
286
|
+
sendMessage: async (sessionName, text) => {
|
|
287
|
+
const session = getSessionBackendSync()?.getSession(sessionName);
|
|
288
|
+
if (!session)
|
|
289
|
+
return false;
|
|
290
|
+
// Same two-step write as the terminal controller's message mode:
|
|
291
|
+
// pasted text first, Enter separately so paste mode cannot swallow it.
|
|
292
|
+
session.write(`\x1b[200~${text}\x1b[201~`);
|
|
293
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(1000 + Math.ceil(text.length / 10), 5000)));
|
|
294
|
+
session.write('\r');
|
|
295
|
+
return true;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Singleton that starts a fresh Claude Code conversation per new task, and
|
|
301
|
+
* caps a long task's conversation at idle boundaries.
|
|
302
|
+
*/
|
|
303
|
+
export class FreshTaskConversationService {
|
|
304
|
+
static instance = null;
|
|
305
|
+
logger;
|
|
306
|
+
deps;
|
|
307
|
+
state = null;
|
|
308
|
+
/** Per-session in-flight prepare (serialises prepares; waited on by terminal writes) */
|
|
309
|
+
inFlight = new Map();
|
|
310
|
+
/** Last time a message was written to a session (terminal write / deliver) */
|
|
311
|
+
lastDelivery = new Map();
|
|
312
|
+
/** Deliveries currently being written to a session (see {@link beginDelivery}) */
|
|
313
|
+
deliveriesInFlight = new Map();
|
|
314
|
+
/** Last context-cap clear per session (rate limit) */
|
|
315
|
+
lastCapAt = new Map();
|
|
316
|
+
/** Context-cap sweep timer */
|
|
317
|
+
capSweepTimer = null;
|
|
318
|
+
/** A sweep is running (sweeps never overlap) */
|
|
319
|
+
capSweepRunning = false;
|
|
320
|
+
constructor(deps) {
|
|
321
|
+
this.logger = LoggerService.getInstance().createComponentLogger('FreshTaskConversation');
|
|
322
|
+
this.deps = { ...defaultDeps(), ...(deps ?? {}) };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* @returns The singleton
|
|
326
|
+
*/
|
|
327
|
+
static getInstance() {
|
|
328
|
+
if (!FreshTaskConversationService.instance) {
|
|
329
|
+
FreshTaskConversationService.instance = new FreshTaskConversationService();
|
|
330
|
+
}
|
|
331
|
+
return FreshTaskConversationService.instance;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Replace the singleton with one using the given collaborators (tests).
|
|
335
|
+
*
|
|
336
|
+
* @param deps - Overrides for the default deps
|
|
337
|
+
* @returns The new singleton
|
|
338
|
+
*/
|
|
339
|
+
static createForTesting(deps) {
|
|
340
|
+
FreshTaskConversationService.instance = new FreshTaskConversationService(deps);
|
|
341
|
+
return FreshTaskConversationService.instance;
|
|
342
|
+
}
|
|
343
|
+
/** Drop the singleton (tests). */
|
|
344
|
+
static resetInstance() {
|
|
345
|
+
FreshTaskConversationService.instance?.stopContextCapSweep();
|
|
346
|
+
FreshTaskConversationService.instance = null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Mark a delivery to a session as in progress until the returned function
|
|
350
|
+
* is called. Neither a new-task clear nor a context-cap clear starts while
|
|
351
|
+
* one is running: it would wipe the message before the agent reads it.
|
|
352
|
+
*
|
|
353
|
+
* @param sessionName - Session being written to
|
|
354
|
+
* @returns Call when the delivery has finished (idempotent)
|
|
355
|
+
*/
|
|
356
|
+
beginDelivery(sessionName) {
|
|
357
|
+
this.deliveriesInFlight.set(sessionName, (this.deliveriesInFlight.get(sessionName) ?? 0) + 1);
|
|
358
|
+
let done = false;
|
|
359
|
+
return () => {
|
|
360
|
+
if (done)
|
|
361
|
+
return;
|
|
362
|
+
done = true;
|
|
363
|
+
const left = (this.deliveriesInFlight.get(sessionName) ?? 1) - 1;
|
|
364
|
+
if (left > 0)
|
|
365
|
+
this.deliveriesInFlight.set(sessionName, left);
|
|
366
|
+
else
|
|
367
|
+
this.deliveriesInFlight.delete(sessionName);
|
|
368
|
+
this.lastDelivery.set(sessionName, this.deps.now());
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Whether a delivery is being written to the session right now.
|
|
373
|
+
*
|
|
374
|
+
* @param sessionName - Session
|
|
375
|
+
* @returns True while a {@link beginDelivery} is open
|
|
376
|
+
*/
|
|
377
|
+
isDelivering(sessionName) {
|
|
378
|
+
return (this.deliveriesInFlight.get(sessionName) ?? 0) > 0;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Record that a message is being written to a session. A clear right after
|
|
382
|
+
* would wipe it before the agent reads it, so {@link prepareForTask} skips
|
|
383
|
+
* clearing for a short window (this includes the dispatcher's own briefs,
|
|
384
|
+
* so two tasks dispatched back to back never clear each other).
|
|
385
|
+
*
|
|
386
|
+
* @param sessionName - Session written to
|
|
387
|
+
*/
|
|
388
|
+
noteDelivery(sessionName) {
|
|
389
|
+
this.lastDelivery.set(sessionName, this.deps.now());
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Wait (bounded) while a prepare — possibly a `/clear` — is running for the
|
|
393
|
+
* session, so a message written now is not wiped by it.
|
|
394
|
+
*
|
|
395
|
+
* @param sessionName - Session about to be written to
|
|
396
|
+
* @param maxMs - Upper bound on the wait
|
|
397
|
+
*/
|
|
398
|
+
async waitIfClearing(sessionName, maxMs = FRESH_TASK_CONVERSATION_CONSTANTS.WAIT_IF_CLEARING_MAX_MS) {
|
|
399
|
+
const pending = this.inFlight.get(sessionName);
|
|
400
|
+
if (!pending)
|
|
401
|
+
return;
|
|
402
|
+
let timer;
|
|
403
|
+
await Promise.race([
|
|
404
|
+
pending.catch(() => undefined),
|
|
405
|
+
new Promise((resolve) => {
|
|
406
|
+
timer = setTimeout(resolve, maxMs);
|
|
407
|
+
timer.unref?.();
|
|
408
|
+
}),
|
|
409
|
+
]);
|
|
410
|
+
if (timer)
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Root of the last task delivered to a session.
|
|
415
|
+
*
|
|
416
|
+
* @param sessionName - Session
|
|
417
|
+
* @returns Root id or null
|
|
418
|
+
*/
|
|
419
|
+
getLastRoot(sessionName) {
|
|
420
|
+
return this.loadState().sessions[sessionName]?.root ?? null;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Called right before a task is written to an agent. Starts a fresh
|
|
424
|
+
* conversation when the task's root differs from the previous one and it
|
|
425
|
+
* is safe to; always records the new root. Never throws.
|
|
426
|
+
*
|
|
427
|
+
* @param sessionName - Target session
|
|
428
|
+
* @param workItem - Task being delivered
|
|
429
|
+
* @returns Whether it cleared, and the handover file when it did
|
|
430
|
+
*/
|
|
431
|
+
async prepareForTask(sessionName, workItem) {
|
|
432
|
+
// A standing-refresh WorkItem re-raises the page an already-idle member
|
|
433
|
+
// was working on; it is not a new task for that member's conversation,
|
|
434
|
+
// so clearing here would /clear a member just to hand it a page refresh.
|
|
435
|
+
// Never record it as a root either — the next real task must still see
|
|
436
|
+
// whatever root preceded this refresh.
|
|
437
|
+
if (workItem.metadata?.['kind'] === STANDING_ANSWERS_CONSTANTS.WORKITEM_KIND) {
|
|
438
|
+
return { cleared: false };
|
|
439
|
+
}
|
|
440
|
+
const previous = this.inFlight.get(sessionName);
|
|
441
|
+
const run = (async () => {
|
|
442
|
+
if (previous)
|
|
443
|
+
await previous.catch(() => undefined);
|
|
444
|
+
try {
|
|
445
|
+
return await this.doPrepare(sessionName, workItem);
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
this.logger.warn('Fresh-conversation prepare failed — delivering without clearing', {
|
|
449
|
+
sessionName,
|
|
450
|
+
workItemId: workItem.id,
|
|
451
|
+
error: err instanceof Error ? err.message : String(err),
|
|
452
|
+
});
|
|
453
|
+
return { cleared: false };
|
|
454
|
+
}
|
|
455
|
+
})();
|
|
456
|
+
this.inFlight.set(sessionName, run);
|
|
457
|
+
try {
|
|
458
|
+
return await run;
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
if (this.inFlight.get(sessionName) === run)
|
|
462
|
+
this.inFlight.delete(sessionName);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// -------------------------------------------------------------------------
|
|
466
|
+
// Idle-boundary context cap
|
|
467
|
+
// -------------------------------------------------------------------------
|
|
468
|
+
/**
|
|
469
|
+
* Start the periodic context-cap sweep (idempotent). The timer is unref'd
|
|
470
|
+
* so it never keeps the process alive.
|
|
471
|
+
*
|
|
472
|
+
* @param intervalMs - Sweep interval
|
|
473
|
+
*/
|
|
474
|
+
startContextCapSweep(intervalMs = FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_SWEEP_MS) {
|
|
475
|
+
if (this.capSweepTimer)
|
|
476
|
+
return;
|
|
477
|
+
this.capSweepTimer = setInterval(() => {
|
|
478
|
+
void this.runContextCapSweep();
|
|
479
|
+
}, intervalMs);
|
|
480
|
+
this.capSweepTimer.unref?.();
|
|
481
|
+
this.logger.info('Member context-cap sweep started', {
|
|
482
|
+
intervalMs,
|
|
483
|
+
capTokens: memberContextCapTokens(this.deps.env),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/** Stop the periodic context-cap sweep. */
|
|
487
|
+
stopContextCapSweep() {
|
|
488
|
+
if (this.capSweepTimer)
|
|
489
|
+
clearInterval(this.capSweepTimer);
|
|
490
|
+
this.capSweepTimer = null;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Check every registered session once. Sessions are handled one after
|
|
494
|
+
* another and sweeps never overlap. Never throws.
|
|
495
|
+
*
|
|
496
|
+
* @returns Per-session results (sessions skipped cheaply are omitted)
|
|
497
|
+
*/
|
|
498
|
+
async runContextCapSweep() {
|
|
499
|
+
const results = {};
|
|
500
|
+
if (this.capSweepRunning)
|
|
501
|
+
return results;
|
|
502
|
+
if (memberContextCapTokens(this.deps.env) <= 0)
|
|
503
|
+
return results;
|
|
504
|
+
this.capSweepRunning = true;
|
|
505
|
+
try {
|
|
506
|
+
let sessions = [];
|
|
507
|
+
try {
|
|
508
|
+
sessions = this.deps.listSessions();
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
sessions = [];
|
|
512
|
+
}
|
|
513
|
+
for (const sessionName of sessions) {
|
|
514
|
+
if (sessionName === ORCHESTRATOR_SESSION_NAME)
|
|
515
|
+
continue;
|
|
516
|
+
results[sessionName] = await this.capContextIfNeeded(sessionName);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
this.capSweepRunning = false;
|
|
521
|
+
}
|
|
522
|
+
return results;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* If the member is idle between turns and its last turn carried more than
|
|
526
|
+
* the cap, save the conversation (handover file + wiki), `/clear` it and
|
|
527
|
+
* write one line naming its WorkItem and the handover. Runs in the same
|
|
528
|
+
* per-session chain as {@link prepareForTask}, so terminal writes wait for
|
|
529
|
+
* it. Never throws.
|
|
530
|
+
*
|
|
531
|
+
* @param sessionName - Member session
|
|
532
|
+
* @returns What happened
|
|
533
|
+
*/
|
|
534
|
+
async capContextIfNeeded(sessionName) {
|
|
535
|
+
// Never queue behind (or start during) a prepare / another cap.
|
|
536
|
+
if (this.inFlight.has(sessionName))
|
|
537
|
+
return { capped: false, reason: 'clear in progress' };
|
|
538
|
+
const run = (async () => {
|
|
539
|
+
try {
|
|
540
|
+
const cap = await this.doCapContext(sessionName);
|
|
541
|
+
return { cleared: cap.capped, handoverPath: cap.handoverPath, cap };
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
this.logger.warn('Context-cap check failed (non-fatal)', {
|
|
545
|
+
sessionName,
|
|
546
|
+
error: err instanceof Error ? err.message : String(err),
|
|
547
|
+
});
|
|
548
|
+
return { cleared: false, cap: { capped: false, reason: 'error' } };
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
this.inFlight.set(sessionName, run);
|
|
552
|
+
try {
|
|
553
|
+
return (await run).cap;
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
if (this.inFlight.get(sessionName) === run)
|
|
557
|
+
this.inFlight.delete(sessionName);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async doCapContext(sessionName) {
|
|
561
|
+
const info = this.deps.getSessionInfo(sessionName);
|
|
562
|
+
const capTokens = memberContextCapTokens(this.deps.env);
|
|
563
|
+
const base = {
|
|
564
|
+
sessionName,
|
|
565
|
+
runtimeType: info?.runtimeType,
|
|
566
|
+
capTokens,
|
|
567
|
+
enabled: true,
|
|
568
|
+
contextTokens: Number.MAX_SAFE_INTEGER,
|
|
569
|
+
busy: false,
|
|
570
|
+
quietMs: Number.MAX_SAFE_INTEGER,
|
|
571
|
+
deliveryActive: false,
|
|
572
|
+
queuedMessages: false,
|
|
573
|
+
lastCapAt: null,
|
|
574
|
+
now: this.deps.now(),
|
|
575
|
+
activeWorkItemId: 'pending',
|
|
576
|
+
};
|
|
577
|
+
// Cheap checks first (orchestrator, runtime, cap off).
|
|
578
|
+
let decision = decideContextCap(base);
|
|
579
|
+
if (!decision.clear)
|
|
580
|
+
return { capped: false, reason: decision.reason };
|
|
581
|
+
if (!info?.cwd || !info.sessionId)
|
|
582
|
+
return { capped: false, reason: 'conversation id unknown' };
|
|
583
|
+
const transcript = claudeTranscriptPath({ sessionId: info.sessionId, cwd: info.cwd, claudeHome: this.deps.claudeHome });
|
|
584
|
+
const contextTokens = fs.existsSync(transcript) ? lastTurnContextTokens(transcript) : null;
|
|
585
|
+
const last = this.lastDelivery.get(sessionName);
|
|
586
|
+
const lastCapAt = this.lastCapAt.get(sessionName) ?? null;
|
|
587
|
+
decision = decideContextCap({ ...base, contextTokens, lastCapAt, now: this.deps.now() });
|
|
588
|
+
if (!decision.clear)
|
|
589
|
+
return { capped: false, reason: decision.reason };
|
|
590
|
+
const enabled = freshTaskConversationEnvEnabled(this.deps.env) && (await this.deps.settingEnabled());
|
|
591
|
+
const busy = enabled ? await this.deps.isBusy(sessionName).catch(() => true) : false;
|
|
592
|
+
const active = enabled && !busy ? await this.deps.getActiveItems(sessionName).catch(() => null) : [];
|
|
593
|
+
const current = this.pickCurrentItem(active ?? []);
|
|
594
|
+
decision = decideContextCap({
|
|
595
|
+
...base,
|
|
596
|
+
contextTokens,
|
|
597
|
+
lastCapAt,
|
|
598
|
+
now: this.deps.now(),
|
|
599
|
+
enabled,
|
|
600
|
+
busy: busy || active === null,
|
|
601
|
+
quietMs: this.deps.getQuietMs(sessionName),
|
|
602
|
+
deliveryActive: this.isDelivering(sessionName) ||
|
|
603
|
+
(last !== undefined && this.deps.now() - last < FRESH_TASK_CONVERSATION_CONSTANTS.RECENT_DELIVERY_MS),
|
|
604
|
+
queuedMessages: this.deps.hasQueuedMessages(sessionName),
|
|
605
|
+
activeWorkItemId: current?.id ?? null,
|
|
606
|
+
});
|
|
607
|
+
if (!decision.clear || !current)
|
|
608
|
+
return { capped: false, reason: decision.reason };
|
|
609
|
+
// Last look right before the clear: anything written since the checks
|
|
610
|
+
// above means the agent is (about to be) working.
|
|
611
|
+
if (this.isDelivering(sessionName) || (await this.deps.isBusy(sessionName).catch(() => true))) {
|
|
612
|
+
return { capped: false, reason: 'busy' };
|
|
613
|
+
}
|
|
614
|
+
const saved = await this.saveAndClear(sessionName, { cwd: info.cwd, sessionId: info.sessionId }, {
|
|
615
|
+
lastTask: current.id,
|
|
616
|
+
why: `because it had grown past ${capTokens.toLocaleString('en-US')} tokens per turn (you are still on the same WorkItem)`,
|
|
617
|
+
});
|
|
618
|
+
if (!saved)
|
|
619
|
+
return { capped: false, reason: 'not cleared' };
|
|
620
|
+
this.lastCapAt.set(sessionName, this.deps.now());
|
|
621
|
+
const line = contextCapReorientation({ workItem: current, handoverPath: saved.handoverPath, contextTokens: contextTokens });
|
|
622
|
+
// Tracking matches on the WorkItem id in the first message, which the
|
|
623
|
+
// re-orientation line carries.
|
|
624
|
+
void this.trackNewConversation(sessionName, info.cwd, info.sessionId, saved.clearAt, current.id);
|
|
625
|
+
const sent = await this.deps.sendMessage(sessionName, line).catch(() => false);
|
|
626
|
+
this.lastDelivery.set(sessionName, this.deps.now());
|
|
627
|
+
this.logger.info('Capped a member conversation at an idle boundary', {
|
|
628
|
+
sessionName,
|
|
629
|
+
workItemId: current.id,
|
|
630
|
+
contextTokens,
|
|
631
|
+
capTokens,
|
|
632
|
+
oldSessionId: info.sessionId,
|
|
633
|
+
handover: saved.handoverPath,
|
|
634
|
+
reoriented: sent,
|
|
635
|
+
});
|
|
636
|
+
return { capped: true, reason: decision.reason, handoverPath: saved.handoverPath, workItemId: current.id };
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The WorkItem a member is on: running before accepted before proposed,
|
|
640
|
+
* then the most recently started.
|
|
641
|
+
*/
|
|
642
|
+
pickCurrentItem(items) {
|
|
643
|
+
const rank = { running: 0, accepted: 1, proposed: 2 };
|
|
644
|
+
const sorted = [...items].sort((a, b) => {
|
|
645
|
+
const r = (rank[a.status] ?? 9) - (rank[b.status] ?? 9);
|
|
646
|
+
if (r !== 0)
|
|
647
|
+
return r;
|
|
648
|
+
return (Date.parse(b.startedAt ?? '') || 0) - (Date.parse(a.startedAt ?? '') || 0);
|
|
649
|
+
});
|
|
650
|
+
return sorted[0] ?? null;
|
|
651
|
+
}
|
|
652
|
+
// -------------------------------------------------------------------------
|
|
653
|
+
// Internals
|
|
654
|
+
// -------------------------------------------------------------------------
|
|
655
|
+
async doPrepare(sessionName, workItem) {
|
|
656
|
+
const newRoot = rootWorkItemId(workItem.id);
|
|
657
|
+
const previousRoot = this.getLastRoot(sessionName);
|
|
658
|
+
const info = this.deps.getSessionInfo(sessionName);
|
|
659
|
+
// Cheap checks first; the async ones only when a clear is still possible.
|
|
660
|
+
let decision = decideFreshConversation({
|
|
661
|
+
sessionName,
|
|
662
|
+
runtimeType: info?.runtimeType,
|
|
663
|
+
previousRoot,
|
|
664
|
+
newRoot,
|
|
665
|
+
enabled: true,
|
|
666
|
+
busy: false,
|
|
667
|
+
recentDelivery: false,
|
|
668
|
+
otherActiveRoots: [],
|
|
669
|
+
});
|
|
670
|
+
if (decision.clear) {
|
|
671
|
+
const enabled = freshTaskConversationEnvEnabled(this.deps.env) && (await this.deps.settingEnabled());
|
|
672
|
+
const busy = enabled ? await this.deps.isBusy(sessionName).catch(() => true) : false;
|
|
673
|
+
const last = this.lastDelivery.get(sessionName);
|
|
674
|
+
const recentDelivery = this.isDelivering(sessionName) ||
|
|
675
|
+
(last !== undefined && this.deps.now() - last < FRESH_TASK_CONVERSATION_CONSTANTS.RECENT_DELIVERY_MS);
|
|
676
|
+
const active = enabled && !busy ? await this.deps.getActiveItems(sessionName).catch(() => null) : [];
|
|
677
|
+
decision = decideFreshConversation({
|
|
678
|
+
sessionName,
|
|
679
|
+
runtimeType: info?.runtimeType,
|
|
680
|
+
previousRoot,
|
|
681
|
+
newRoot,
|
|
682
|
+
enabled,
|
|
683
|
+
busy: busy || active === null,
|
|
684
|
+
recentDelivery,
|
|
685
|
+
otherActiveRoots: (active ?? []).filter((wi) => wi.id !== workItem.id).map((wi) => rootWorkItemId(wi.id)),
|
|
686
|
+
alreadyStarted: (active ?? []).some((wi) => rootWorkItemId(wi.id) === newRoot &&
|
|
687
|
+
!!wi.startedAt &&
|
|
688
|
+
this.deps.now() - Date.parse(wi.startedAt) > FRESH_TASK_CONVERSATION_CONSTANTS.ALREADY_STARTED_MS),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
this.recordRoot(sessionName, newRoot);
|
|
692
|
+
if (!decision.clear) {
|
|
693
|
+
if (previousRoot && previousRoot !== newRoot) {
|
|
694
|
+
this.logger.debug('New task — keeping the current conversation', { sessionName, newRoot, previousRoot, reason: decision.reason });
|
|
695
|
+
}
|
|
696
|
+
return { cleared: false };
|
|
697
|
+
}
|
|
698
|
+
if (!info?.cwd || !info.sessionId) {
|
|
699
|
+
this.logger.info('New task but the conversation id is unknown — not clearing', { sessionName, newRoot });
|
|
700
|
+
return { cleared: false };
|
|
701
|
+
}
|
|
702
|
+
const saved = await this.saveAndClear(sessionName, { cwd: info.cwd, sessionId: info.sessionId }, {
|
|
703
|
+
lastTask: previousRoot,
|
|
704
|
+
why: 'when you were given a new task',
|
|
705
|
+
});
|
|
706
|
+
if (!saved)
|
|
707
|
+
return { cleared: false };
|
|
708
|
+
this.logger.info('Started a fresh conversation for a new task', {
|
|
709
|
+
sessionName,
|
|
710
|
+
previousRoot,
|
|
711
|
+
newRoot,
|
|
712
|
+
oldSessionId: info.sessionId,
|
|
713
|
+
handover: saved.handoverPath,
|
|
714
|
+
});
|
|
715
|
+
// 3. Learn the new conversation id in the background.
|
|
716
|
+
void this.trackNewConversation(sessionName, info.cwd, info.sessionId, saved.clearAt, workItem.id);
|
|
717
|
+
return { cleared: true, handoverPath: saved.handoverPath };
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Save the current conversation (handover file + memory/wiki copy, no
|
|
721
|
+
* agent turn) and write `/clear`. Shared by the new-task and the
|
|
722
|
+
* context-cap paths.
|
|
723
|
+
*
|
|
724
|
+
* @param sessionName - Agent session
|
|
725
|
+
* @param conv - Its cwd and current conversation id
|
|
726
|
+
* @param handover - Task the old conversation was on, and why it closed
|
|
727
|
+
* @returns The handover file and the time `/clear` was written, or null
|
|
728
|
+
* when nothing was cleared (transcript missing, session gone)
|
|
729
|
+
*/
|
|
730
|
+
async saveAndClear(sessionName, conv, handover) {
|
|
731
|
+
const transcript = claudeTranscriptPath({ sessionId: conv.sessionId, cwd: conv.cwd, claudeHome: this.deps.claudeHome });
|
|
732
|
+
if (!fs.existsSync(transcript)) {
|
|
733
|
+
this.logger.info('Transcript missing — not clearing', { sessionName, transcript });
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
// 1. Save the conversation (server-side, no agent turn).
|
|
737
|
+
const summary = buildHandoverSummary(transcript);
|
|
738
|
+
const handoverPath = this.writeHandover(sessionName, conv.sessionId, transcript, handover.lastTask, summary, handover.why);
|
|
739
|
+
const memoryText = this.conciseHandover(sessionName, handover.lastTask, handoverPath, summary);
|
|
740
|
+
void this.deps
|
|
741
|
+
.remember({
|
|
742
|
+
agentId: sessionName,
|
|
743
|
+
projectPath: conv.cwd,
|
|
744
|
+
content: memoryText,
|
|
745
|
+
title: `Conversation handover — ${sessionName} — task ${handover.lastTask}`,
|
|
746
|
+
})
|
|
747
|
+
.catch((err) => {
|
|
748
|
+
this.logger.debug('Handover memory write failed (non-fatal; the file is on disk)', {
|
|
749
|
+
sessionName,
|
|
750
|
+
error: err instanceof Error ? err.message : String(err),
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
// 2. Clear, exactly as context compaction writes its command.
|
|
754
|
+
const clearAt = this.deps.now();
|
|
755
|
+
if (!this.deps.writeToSession(sessionName, '\x1b')) {
|
|
756
|
+
this.logger.info('Session not found — not clearing', { sessionName });
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
await this.deps.sleep(FRESH_TASK_CONVERSATION_CONSTANTS.ESCAPE_DELAY_MS);
|
|
760
|
+
this.deps.writeToSession(sessionName, `${FRESH_TASK_CONVERSATION_CONSTANTS.CLEAR_COMMAND}\r`);
|
|
761
|
+
await this.deps.sleep(FRESH_TASK_CONVERSATION_CONSTANTS.POST_CLEAR_READY_MS);
|
|
762
|
+
return { handoverPath, clearAt };
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Write the handover file (same shape as the orchestrator's restart handover).
|
|
766
|
+
*
|
|
767
|
+
* @returns Its path
|
|
768
|
+
*/
|
|
769
|
+
writeHandover(sessionName, oldSessionId, transcript, previousRoot, body, why = 'when you were given a new task') {
|
|
770
|
+
const dir = path.join(this.deps.crewlyHome(), ORC_CONVERSATION_CONSTANTS.HANDOVER_DIR);
|
|
771
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
772
|
+
const stamp = new Date(this.deps.now()).toISOString().replace(/[:.]/g, '-');
|
|
773
|
+
const file = path.join(dir, `${sessionName}-${stamp}.md`);
|
|
774
|
+
const tokens = lastTurnContextTokens(transcript);
|
|
775
|
+
fs.writeFileSync(file, [
|
|
776
|
+
`# Handover from your previous conversation`,
|
|
777
|
+
``,
|
|
778
|
+
`Your previous conversation (${oldSessionId}) was closed ${why}; its last task was ${previousRoot}.` +
|
|
779
|
+
(tokens !== null ? ` It had grown to ${tokens.toLocaleString('en-US')} tokens per turn.` : ''),
|
|
780
|
+
`Everything Crewly tracks — tasks, teams, OKRs, wiki — is still there; this file keeps only the end of what was said.`,
|
|
781
|
+
`The full transcript: ${transcript}`,
|
|
782
|
+
``,
|
|
783
|
+
body || '_Nothing readable was found at the end of the old conversation._',
|
|
784
|
+
``,
|
|
785
|
+
].join('\n'), 'utf-8');
|
|
786
|
+
return file;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* The capped text stored through memory: where the handover is plus the
|
|
790
|
+
* newest part of what was said.
|
|
791
|
+
*/
|
|
792
|
+
conciseHandover(sessionName, previousRoot, handoverPath, summary) {
|
|
793
|
+
const head = `${sessionName} finished its conversation on task ${previousRoot}; full handover: ${handoverPath}\n\n`;
|
|
794
|
+
const room = Math.max(0, FRESH_TASK_CONVERSATION_CONSTANTS.MEMORY_MAX_CHARS - head.length);
|
|
795
|
+
const tail = summary.length > room ? `…${summary.slice(summary.length - room + 1)}` : summary;
|
|
796
|
+
return head + (tail || '(nothing readable at the end of the conversation)');
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* After `/clear`, find the transcript Claude Code starts for this agent and
|
|
800
|
+
* store its id; if none shows up in time, clear the stored id so a restart
|
|
801
|
+
* starts fresh rather than resuming the pre-clear conversation.
|
|
802
|
+
*
|
|
803
|
+
* A candidate is a `.jsonl` in the agent's transcript directory that was
|
|
804
|
+
* STARTED after the clear (its first entry, not just a later write), is not
|
|
805
|
+
* the old id or claimed by another session, and contains this task's
|
|
806
|
+
* work-item id — the dispatch text written right after the clear carries
|
|
807
|
+
* it. Several agents share one cwd and their transcripts mention each
|
|
808
|
+
* other's session names, so matching on the name alone picked another
|
|
809
|
+
* agent's conversation (Atlas resumed Ella's, 2026-09-28).
|
|
810
|
+
*/
|
|
811
|
+
async trackNewConversation(sessionName, cwd, oldId, clearAt, workItemId) {
|
|
812
|
+
try {
|
|
813
|
+
const dir = path.dirname(claudeTranscriptPath({ sessionId: oldId, cwd, claudeHome: this.deps.claudeHome }));
|
|
814
|
+
const deadline = clearAt + FRESH_TASK_CONVERSATION_CONSTANTS.NEW_SESSION_DETECT_MS;
|
|
815
|
+
while (this.deps.now() <= deadline) {
|
|
816
|
+
const found = this.findNewTranscript(dir, sessionName, oldId, clearAt, workItemId);
|
|
817
|
+
if (found) {
|
|
818
|
+
this.deps.updateSessionId(sessionName, found);
|
|
819
|
+
this.logger.info('Recorded the conversation id started by /clear', { sessionName, sessionId: found });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
await this.deps.sleep(FRESH_TASK_CONVERSATION_CONSTANTS.NEW_SESSION_POLL_MS);
|
|
823
|
+
}
|
|
824
|
+
this.deps.clearSessionId(sessionName);
|
|
825
|
+
this.logger.warn('New conversation id not found after /clear — cleared the stored id so a restart starts fresh', {
|
|
826
|
+
sessionName,
|
|
827
|
+
oldSessionId: oldId,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
this.logger.warn('New conversation id tracking failed — clearing the stored id', {
|
|
832
|
+
sessionName,
|
|
833
|
+
error: err instanceof Error ? err.message : String(err),
|
|
834
|
+
});
|
|
835
|
+
try {
|
|
836
|
+
this.deps.clearSessionId(sessionName);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
// nothing more to do
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* One scan of the transcript directory.
|
|
845
|
+
*
|
|
846
|
+
* @returns The new conversation id, or null
|
|
847
|
+
*/
|
|
848
|
+
findNewTranscript(dir, sessionName, oldId, clearAt, workItemId) {
|
|
849
|
+
let entries;
|
|
850
|
+
try {
|
|
851
|
+
entries = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
const claimed = this.deps.getClaimedSessionIds(sessionName);
|
|
857
|
+
const candidates = [];
|
|
858
|
+
for (const name of entries) {
|
|
859
|
+
const id = name.slice(0, -'.jsonl'.length);
|
|
860
|
+
if (id === oldId || claimed.has(id))
|
|
861
|
+
continue;
|
|
862
|
+
const file = path.join(dir, name);
|
|
863
|
+
let mtime;
|
|
864
|
+
try {
|
|
865
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
// One second of slack for filesystem timestamp granularity.
|
|
871
|
+
if (mtime < clearAt - 1_000)
|
|
872
|
+
continue;
|
|
873
|
+
if (!this.startedAfter(file, clearAt - 1_000))
|
|
874
|
+
continue;
|
|
875
|
+
if (!this.fileMentions(file, workItemId))
|
|
876
|
+
continue;
|
|
877
|
+
candidates.push({ id, mtime });
|
|
878
|
+
}
|
|
879
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
880
|
+
return candidates[0]?.id ?? null;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Whether a transcript's first timestamped entry is at or after `since`,
|
|
884
|
+
* i.e. the conversation began after the clear rather than being an older
|
|
885
|
+
* one that happened to be written to recently.
|
|
886
|
+
*/
|
|
887
|
+
startedAfter(file, since) {
|
|
888
|
+
try {
|
|
889
|
+
const fd = fs.openSync(file, 'r');
|
|
890
|
+
try {
|
|
891
|
+
const buf = Buffer.alloc(Math.min(64 * 1024, fs.fstatSync(fd).size));
|
|
892
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
893
|
+
for (const line of buf.toString('utf-8').split('\n')) {
|
|
894
|
+
const m = /"timestamp":"([^"]+)"/.exec(line);
|
|
895
|
+
if (m)
|
|
896
|
+
return Date.parse(m[1]) >= since;
|
|
897
|
+
}
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
finally {
|
|
901
|
+
fs.closeSync(fd);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** Whether the start of a transcript contains the given text. */
|
|
909
|
+
fileMentions(file, needle) {
|
|
910
|
+
const MAX_BYTES = 256 * 1024;
|
|
911
|
+
try {
|
|
912
|
+
const fd = fs.openSync(file, 'r');
|
|
913
|
+
try {
|
|
914
|
+
const buf = Buffer.alloc(Math.min(MAX_BYTES, fs.fstatSync(fd).size));
|
|
915
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
916
|
+
return buf.toString('utf-8').includes(needle);
|
|
917
|
+
}
|
|
918
|
+
finally {
|
|
919
|
+
fs.closeSync(fd);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
statePath() {
|
|
927
|
+
return path.join(this.deps.crewlyHome(), FRESH_TASK_CONVERSATION_CONSTANTS.STATE_FILE);
|
|
928
|
+
}
|
|
929
|
+
loadState() {
|
|
930
|
+
if (this.state)
|
|
931
|
+
return this.state;
|
|
932
|
+
try {
|
|
933
|
+
const parsed = JSON.parse(fs.readFileSync(this.statePath(), 'utf-8'));
|
|
934
|
+
this.state = { version: 1, sessions: parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {} };
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
this.state = { version: 1, sessions: {} };
|
|
938
|
+
}
|
|
939
|
+
return this.state;
|
|
940
|
+
}
|
|
941
|
+
recordRoot(sessionName, root) {
|
|
942
|
+
const state = this.loadState();
|
|
943
|
+
if (state.sessions[sessionName]?.root === root)
|
|
944
|
+
return;
|
|
945
|
+
state.sessions[sessionName] = { root, at: new Date(this.deps.now()).toISOString() };
|
|
946
|
+
try {
|
|
947
|
+
const file = this.statePath();
|
|
948
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
949
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
950
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
|
|
951
|
+
fs.renameSync(tmp, file);
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
this.logger.debug('Could not persist the last task root (non-fatal)', {
|
|
955
|
+
sessionName,
|
|
956
|
+
error: err instanceof Error ? err.message : String(err),
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
//# sourceMappingURL=fresh-task-conversation.service.js.map
|