crewly 1.20.213 → 1.20.215
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/project-tickets/SKILL.md +42 -10
- package/config/skills/agent/core/project-tickets/execute.sh +45 -12
- package/config/skills/agent/core/project-tickets/execute.test.sh +19 -0
- package/dist/backend/backend/src/constants.d.ts +124 -9
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +152 -8
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts +9 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +13 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.d.ts +2 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.js +4 -2
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.d.ts +37 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.js +97 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.d.ts +18 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.js +25 -0
- package/dist/backend/backend/src/controllers/talk/talk-transcribe.routes.js.map +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +22 -4
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +4 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +6 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.d.ts +108 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.d.ts.map +1 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.js +223 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed-command.js.map +1 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.d.ts +57 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.js +90 -0
- package/dist/backend/backend/src/services/project-tickets/autopilot-speed.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts +18 -4
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js +18 -4
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts +135 -14
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js +111 -18
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts +67 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js +75 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.d.ts +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.js +7 -0
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-trace.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +99 -4
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +422 -11
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.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 +2 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/talk/talk-transcribe.service.d.ts +344 -0
- package/dist/backend/backend/src/services/talk/talk-transcribe.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/talk/talk-transcribe.service.js +642 -0
- package/dist/backend/backend/src/services/talk/talk-transcribe.service.js.map +1 -0
- package/dist/backend/backend/src/types/ticket-autopilot.types.d.ts +46 -4
- package/dist/backend/backend/src/types/ticket-autopilot.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/ticket-autopilot.types.js +43 -2
- package/dist/backend/backend/src/types/ticket-autopilot.types.js.map +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.d.ts +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.js +1 -0
- package/dist/backend/backend/src/types/v2/work-item.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +124 -9
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +152 -8
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/types/ticket-autopilot.types.d.ts +46 -4
- package/dist/cli/backend/src/types/ticket-autopilot.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/ticket-autopilot.types.js +43 -2
- package/dist/cli/backend/src/types/ticket-autopilot.types.js.map +1 -1
- package/dist/cli/backend/src/types/v2/work-item.types.d.ts +1 -1
- package/dist/cli/backend/src/types/v2/work-item.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/work-item.types.js +1 -0
- package/dist/cli/backend/src/types/v2/work-item.types.js.map +1 -1
- package/frontend/dist/assets/{index-917db1b8.js → index-839f4374.js} +272 -272
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
* and in-progress brakes; specs/2026-10-04-autopilot-goal-replan.md);
|
|
17
17
|
* - owner questions are decision cards (specs/2026-10-01-decision-cards.md),
|
|
18
18
|
* posted by the asking agent in the ticket's thread; the owner gets one
|
|
19
|
-
* evening digest (skipped when nothing changed) that links to open cards
|
|
19
|
+
* evening digest (skipped when nothing changed) that links to open cards;
|
|
20
|
+
* - a speed mode (Rush / Normal / Chill, specs/2026-10-04-autopilot-speed-modes.md)
|
|
21
|
+
* sets the replan gap and daily cap, the retry after an empty replan, the
|
|
22
|
+
* driver's self-review cadence and the default budget; tickets a replan
|
|
23
|
+
* opens must name the goal metric they move; and why the autopilot stopped
|
|
24
|
+
* (paused / budget / system error / waiting on the owner / no ideas) is
|
|
25
|
+
* traced and shown in the status and the digest.
|
|
20
26
|
*
|
|
21
27
|
* The approval boundary is unchanged: the autopilot only wakes the lead and
|
|
22
28
|
* talks to the owner; it never makes a ticket ready or starts work itself.
|
|
@@ -33,8 +39,8 @@ import { ProjectTicketError } from './project-ticket.service.js';
|
|
|
33
39
|
import { getTeamLeads } from '../../utils/team.utils.js';
|
|
34
40
|
import { isTeamPausedNow } from '../team/team-pause.registry.js';
|
|
35
41
|
import { isTeamLead, } from './project-ticket-workflow.service.js';
|
|
36
|
-
import { OPEN_TICKET_STATUSES, closedTicketsSince, decideDigest, decideReplan, decideTriage, nextReplanBackoff, replanBackoffState, hasNeedsOwnerLabel, inFlightByAssignee, isMemberIdle, localDateKey, memberAvailability, memberResponsibility, localMidnight, selectTriageCandidates, } from './ticket-autopilot-decision.js';
|
|
37
|
-
import { buildBudgetPausedMessage, buildDigestMessage, buildReplanBrief, buildTriageBrief, REPLAN_ASK, } from './ticket-autopilot-messages.js';
|
|
42
|
+
import { OPEN_TICKET_STATUSES, classifyStopReason, closedTicketsSince, decideDigest, decideReplan, decideSelfReview, decideTriage, nextReplanBackoff, replanBackoffHolds, replanBackoffState, ticketMetricRef, hasNeedsOwnerLabel, inFlightByAssignee, isMemberIdle, localDateKey, memberAvailability, memberResponsibility, localMidnight, selectTriageCandidates, } from './ticket-autopilot-decision.js';
|
|
43
|
+
import { buildBudgetPausedMessage, buildDigestMessage, buildReplanBrief, buildSelfReviewBrief, buildTriageBrief, REPLAN_ASK, replanMetricRejection, STOP_REASON_WORDS, } from './ticket-autopilot-messages.js';
|
|
38
44
|
import { autopilotRunTrace, startReplanTrace, startTriageTrace, traceAutopilotAction, createAutopilotTicketListener } from './ticket-autopilot-trace.js';
|
|
39
45
|
import { computeAutopilotStats, dayEndMs, daysBetween, dayStartMs, rangeDays, } from './ticket-autopilot-stats.js';
|
|
40
46
|
import { buildRetroBrief, duplicateOf, renderRetroMarkdown, RetroInputError, validateRetroInput, } from './ticket-autopilot-retro.js';
|
|
@@ -156,8 +162,48 @@ export class TicketAutopilotService {
|
|
|
156
162
|
const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
|
|
157
163
|
return settings.enabled ? settings.maxInFlightPerMember : null;
|
|
158
164
|
},
|
|
165
|
+
checkCreate: (project, caller, input) => this.checkReplanTicket(project, caller, input),
|
|
159
166
|
};
|
|
160
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* The metric rule of replan tickets (specs/2026-10-04-autopilot-speed-modes.md §3):
|
|
170
|
+
* a ticket created by the driver of a live goal replan of the project must
|
|
171
|
+
* name the goal metric it moves. The owner's, the orchestrator's and any
|
|
172
|
+
* ticket created outside a replan are never checked.
|
|
173
|
+
*
|
|
174
|
+
* @param project - Project
|
|
175
|
+
* @param caller - Who creates the ticket
|
|
176
|
+
* @param input - The create input
|
|
177
|
+
* @returns Why it is refused (sent back to the agent), or null to allow it
|
|
178
|
+
*/
|
|
179
|
+
async checkReplanTicket(project, caller, input) {
|
|
180
|
+
if (!caller.session || caller.session === ORCHESTRATOR_SESSION_NAME)
|
|
181
|
+
return null;
|
|
182
|
+
const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
|
|
183
|
+
if (!settings.enabled)
|
|
184
|
+
return null;
|
|
185
|
+
const nowMs = this.now().getTime();
|
|
186
|
+
const ttlMs = settings.replanTtlHours * 60 * 60 * 1000;
|
|
187
|
+
const replan = (await this.deps.pool.getAllItems()).find((wi) => wi.metadata?.kind === TICKET_AUTOPILOT_CONSTANTS.REPLAN_METADATA_KIND &&
|
|
188
|
+
wi.metadata?.projectId === project.id &&
|
|
189
|
+
wi.target === caller.session &&
|
|
190
|
+
LIVE_STATUSES.has(wi.status) &&
|
|
191
|
+
nowMs - (Date.parse(wi.createdAt) || nowMs) < ttlMs);
|
|
192
|
+
if (!replan)
|
|
193
|
+
return null;
|
|
194
|
+
if (ticketMetricRef(input))
|
|
195
|
+
return null;
|
|
196
|
+
traceAutopilotAction(project, 'replan_ticket_rejected', {
|
|
197
|
+
summary: `${project.name}: a replan ticket without a goal metric was refused ("${String(input.title ?? '').slice(0, 80)}")`,
|
|
198
|
+
outcome: 'blocked',
|
|
199
|
+
workItemId: replan.id,
|
|
200
|
+
session: caller.session,
|
|
201
|
+
data: { title: String(input.title ?? '').slice(0, 140) },
|
|
202
|
+
alsoTraceId: replan.traceId ?? null,
|
|
203
|
+
now: this.now(),
|
|
204
|
+
});
|
|
205
|
+
return replanMetricRejection(String(input.title ?? ''), project.name);
|
|
206
|
+
}
|
|
161
207
|
// ---------------------------------------------------------------------------
|
|
162
208
|
// Settings (owner / orchestrator only)
|
|
163
209
|
// ---------------------------------------------------------------------------
|
|
@@ -203,8 +249,56 @@ export class TicketAutopilotService {
|
|
|
203
249
|
by: caller.session ?? 'owner',
|
|
204
250
|
settings: result.settings,
|
|
205
251
|
});
|
|
252
|
+
const before = resolveTicketAutopilotSettings(project.ticketAutopilot);
|
|
253
|
+
const after = resolveTicketAutopilotSettings(result.settings);
|
|
254
|
+
if (before.speedMode !== after.speedMode)
|
|
255
|
+
await this.onSpeedModeChanged(updated, before.speedMode, after, caller);
|
|
206
256
|
return this.statusOf(updated);
|
|
207
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* A project's speed mode changed: re-time a backoff that still holds to the
|
|
260
|
+
* new mode's retry (switching to Rush must not wait out Normal's "next
|
|
261
|
+
* day"), and trace the change.
|
|
262
|
+
*
|
|
263
|
+
* @param project - The saved project
|
|
264
|
+
* @param from - Previous mode
|
|
265
|
+
* @param settings - New resolved settings
|
|
266
|
+
* @param caller - Who changed it
|
|
267
|
+
*/
|
|
268
|
+
async onSpeedModeChanged(project, from, settings, caller) {
|
|
269
|
+
const state = await this.loadState();
|
|
270
|
+
const ps = state.projects[project.id];
|
|
271
|
+
const now = this.now();
|
|
272
|
+
const backoff = ps?.replanBackoff;
|
|
273
|
+
if (ps && backoff && replanBackoffHolds(backoff, now.getTime(), localDateKey(now))) {
|
|
274
|
+
const retry = settings.emptyReplanRetry;
|
|
275
|
+
let resumeAt;
|
|
276
|
+
if (retry.unit === 'hours')
|
|
277
|
+
resumeAt = backoff.since + retry.amount * 60 * 60 * 1000;
|
|
278
|
+
else {
|
|
279
|
+
const d = new Date(backoff.since);
|
|
280
|
+
resumeAt = new Date(d.getFullYear(), d.getMonth(), d.getDate() + Math.max(1, retry.amount), 0, 0, 0, 0).getTime();
|
|
281
|
+
}
|
|
282
|
+
ps.replanBackoff = { ...backoff, resumeAt, resumeDay: localDateKey(new Date(resumeAt)) };
|
|
283
|
+
await this.saveState();
|
|
284
|
+
}
|
|
285
|
+
traceAutopilotAction(project, 'mode_changed', {
|
|
286
|
+
summary: `${project.name}: autopilot speed ${from} → ${settings.speedMode}`,
|
|
287
|
+
outcome: 'ok',
|
|
288
|
+
...(caller.session ? { session: caller.session, actor: { kind: 'agent', session: caller.session } } : { actor: { kind: 'owner' } }),
|
|
289
|
+
data: {
|
|
290
|
+
from,
|
|
291
|
+
to: settings.speedMode,
|
|
292
|
+
replansPerDay: settings.replansPerDay,
|
|
293
|
+
replanMinGapMs: settings.replanMinGapMs,
|
|
294
|
+
selfReviewEveryMs: settings.selfReviewEveryMs,
|
|
295
|
+
dailyBudgetTokens: settings.dailyBudgetTokens,
|
|
296
|
+
budgetSource: settings.budgetSource,
|
|
297
|
+
},
|
|
298
|
+
now,
|
|
299
|
+
});
|
|
300
|
+
this.logger.info('Ticket autopilot speed mode changed', { projectId: project.id, from, to: settings.speedMode, by: caller.session ?? 'owner' });
|
|
301
|
+
}
|
|
208
302
|
// ---------------------------------------------------------------------------
|
|
209
303
|
// Triggers
|
|
210
304
|
// ---------------------------------------------------------------------------
|
|
@@ -228,6 +322,8 @@ export class TicketAutopilotService {
|
|
|
228
322
|
}
|
|
229
323
|
}
|
|
230
324
|
if (projects.length > 0) {
|
|
325
|
+
await this.processStopReasons(projects).catch((err) => this.logger.warn('Autopilot stop-reason pass failed', { error: err instanceof Error ? err.message : String(err) }));
|
|
326
|
+
await this.processSelfReviews(projects).catch((err) => this.logger.warn('Autopilot self-review pass failed', { error: err instanceof Error ? err.message : String(err) }));
|
|
231
327
|
await this.processDigest(projects).catch((err) => this.logger.warn('Ticket digest pass failed', { error: err instanceof Error ? err.message : String(err) }));
|
|
232
328
|
await this.processRetros(projects).catch((err) => this.logger.warn('Autopilot retro pass failed', { error: err instanceof Error ? err.message : String(err) }));
|
|
233
329
|
}
|
|
@@ -422,14 +518,18 @@ export class TicketAutopilotService {
|
|
|
422
518
|
const held = (m) => Math.max(inFlight.get(sessionOf(m)) ?? 0, m.sessionName ? inFlight.get(m.sessionName) ?? 0 : 0, m.agentId ? inFlight.get(m.agentId) ?? 0 : 0);
|
|
423
519
|
const idleWithRoom = members.some((m) => isIdle(m) && held(m) < c.settings.maxInFlightPerMember);
|
|
424
520
|
const replansToday = c.ps.replans?.day === today ? c.ps.replans.count : 0;
|
|
425
|
-
// The last replan is over: did it open tickets? None →
|
|
426
|
-
this.assessLastReplan(project, c.ps, c.tickets, c.liveReplanId, nowMs);
|
|
521
|
+
// The last replan is over: did it open tickets? None → wait for the mode's retry.
|
|
522
|
+
this.assessLastReplan(project, c.ps, c.tickets, c.liveReplanId, nowMs, c.settings.emptyReplanRetry);
|
|
427
523
|
// Cheap gates first (in-memory state only); the goal is read last.
|
|
428
524
|
const gates = {
|
|
429
525
|
enabled: c.settings.enabled,
|
|
430
526
|
driver: c.driver.session,
|
|
431
527
|
maxReplansPerDay: c.settings.replansPerDay,
|
|
432
528
|
replansToday,
|
|
529
|
+
// The speed mode's gap counts from the last replan, across days.
|
|
530
|
+
...(c.ps.replans?.lastAt !== undefined ? { lastReplanAt: c.ps.replans.lastAt } : {}),
|
|
531
|
+
minGapMs: c.settings.replanMinGapMs,
|
|
532
|
+
now: nowMs,
|
|
433
533
|
usedTodayTokens: c.spent,
|
|
434
534
|
dailyBudgetTokens: c.budget,
|
|
435
535
|
liveTriage: c.liveTriage,
|
|
@@ -450,7 +550,7 @@ export class TicketAutopilotService {
|
|
|
450
550
|
let backedOff = false;
|
|
451
551
|
if (c.ps.replanBackoff) {
|
|
452
552
|
const changedAt = this.deps.goalChangedAt ? await this.deps.goalChangedAt(project).catch(() => null) : null;
|
|
453
|
-
const state = replanBackoffState(c.ps.replanBackoff, { today, tickets: c.tickets, goalChangedAt: changedAt });
|
|
553
|
+
const state = replanBackoffState(c.ps.replanBackoff, { today, tickets: c.tickets, goalChangedAt: changedAt, now: nowMs });
|
|
454
554
|
if (state === 'lifted') {
|
|
455
555
|
this.logger.info('Goal replan backoff lifted (new ticket or goal change)', { projectId: project.id, streak: c.ps.replanBackoff.streak });
|
|
456
556
|
delete c.ps.replanBackoff;
|
|
@@ -481,6 +581,7 @@ export class TicketAutopilotService {
|
|
|
481
581
|
members: await this.briefMembers(c.teams, c.tickets, c.idleSession),
|
|
482
582
|
maxInFlightPerMember: c.settings.maxInFlightPerMember,
|
|
483
583
|
now: nowMs,
|
|
584
|
+
lastSelfReview: c.ps.selfReviews?.[c.ps.selfReviews.length - 1] ?? null,
|
|
484
585
|
});
|
|
485
586
|
const workItem = createWorkItem({
|
|
486
587
|
type: C.REPLAN_WORK_ITEM_TYPE,
|
|
@@ -522,6 +623,7 @@ export class TicketAutopilotService {
|
|
|
522
623
|
experiments: experiments.length,
|
|
523
624
|
replansToday: count,
|
|
524
625
|
replansPerDay: c.settings.replansPerDay,
|
|
626
|
+
speedMode: c.settings.speedMode,
|
|
525
627
|
},
|
|
526
628
|
alsoTraceId: workItem.traceId ?? null,
|
|
527
629
|
now: c.now,
|
|
@@ -577,21 +679,23 @@ export class TicketAutopilotService {
|
|
|
577
679
|
/**
|
|
578
680
|
* Record the outcome of the last goal replan once it is no longer live:
|
|
579
681
|
* a ticket created since it was queued clears the backoff; none ("there
|
|
580
|
-
* are none")
|
|
682
|
+
* are none") waits for the speed mode's retry (Rush 1 h, Normal the next
|
|
683
|
+
* day, Chill a week).
|
|
581
684
|
*
|
|
582
685
|
* @param project - Project
|
|
583
686
|
* @param ps - Its bookkeeping
|
|
584
687
|
* @param tickets - Its tickets
|
|
585
688
|
* @param liveReplanId - The live replan, if any
|
|
586
689
|
* @param nowMs - Clock
|
|
690
|
+
* @param retry - The speed mode's retry after an empty replan
|
|
587
691
|
*/
|
|
588
|
-
assessLastReplan(project, ps, tickets, liveReplanId, nowMs) {
|
|
692
|
+
assessLastReplan(project, ps, tickets, liveReplanId, nowMs, retry) {
|
|
589
693
|
const last = ps.replans;
|
|
590
694
|
if (!last?.lastWorkItemId || last.lastAt === undefined || last.assessed !== false)
|
|
591
695
|
return;
|
|
592
696
|
if (liveReplanId === last.lastWorkItemId)
|
|
593
697
|
return;
|
|
594
|
-
const next = nextReplanBackoff({ replanAt: last.lastAt, replanDay: last.day, tickets, previous: ps.replanBackoff, now: nowMs });
|
|
698
|
+
const next = nextReplanBackoff({ replanAt: last.lastAt, replanDay: last.day, tickets, previous: ps.replanBackoff, retry, now: nowMs });
|
|
595
699
|
if (next)
|
|
596
700
|
ps.replanBackoff = next;
|
|
597
701
|
else
|
|
@@ -600,7 +704,7 @@ export class TicketAutopilotService {
|
|
|
600
704
|
this.logger.info(next ? 'Goal replan opened no tickets: backing off' : 'Goal replan opened tickets', {
|
|
601
705
|
projectId: project.id,
|
|
602
706
|
workItemId: last.lastWorkItemId,
|
|
603
|
-
...(next ? { streak: next.streak, resumeDay: next.resumeDay } : {}),
|
|
707
|
+
...(next ? { streak: next.streak, resumeDay: next.resumeDay, resumeAt: next.resumeAt } : {}),
|
|
604
708
|
});
|
|
605
709
|
}
|
|
606
710
|
/**
|
|
@@ -712,12 +816,17 @@ export class TicketAutopilotService {
|
|
|
712
816
|
links.set(t.id, link);
|
|
713
817
|
}
|
|
714
818
|
}
|
|
819
|
+
const ps = state.projects[project.id];
|
|
820
|
+
const review = ps?.selfReviews?.[ps.selfReviews.length - 1] ?? null;
|
|
821
|
+
const freshReview = review && now.getTime() - (Date.parse(review.at) || 0) <= TICKET_AUTOPILOT_CONSTANTS.SELF_REVIEW_DIGEST_MAX_AGE_MS ? review : null;
|
|
715
822
|
sections.push({
|
|
716
823
|
name: project.name,
|
|
717
824
|
doneToday: tickets.filter((t) => t.status === 'done' && (Date.parse(t.updatedAt) || 0) >= midnight),
|
|
718
825
|
inProgress: tickets.filter((t) => t.status === 'in_progress'),
|
|
719
826
|
waitingOnOwner,
|
|
720
827
|
...(links.size > 0 ? { links } : {}),
|
|
828
|
+
...(ps?.stop ? { stopReason: ps.stop.reason } : {}),
|
|
829
|
+
...(freshReview ? { selfReview: freshReview } : {}),
|
|
721
830
|
});
|
|
722
831
|
}
|
|
723
832
|
const message = buildDigestMessage(sections);
|
|
@@ -1428,6 +1537,297 @@ export class TicketAutopilotService {
|
|
|
1428
1537
|
return null;
|
|
1429
1538
|
}
|
|
1430
1539
|
// ---------------------------------------------------------------------------
|
|
1540
|
+
// Stop reasons and self-review (specs/2026-10-04-autopilot-speed-modes.md)
|
|
1541
|
+
// ---------------------------------------------------------------------------
|
|
1542
|
+
/**
|
|
1543
|
+
* Why a project's autopilot is not producing work right now (see
|
|
1544
|
+
* {@link classifyStopReason}). Reads only: the pool, the tickets, the ledger.
|
|
1545
|
+
*
|
|
1546
|
+
* @param project - Project
|
|
1547
|
+
* @param ps - Its bookkeeping (listed tickets, replan backoff)
|
|
1548
|
+
* @param items - The pool's items (read once per pass)
|
|
1549
|
+
* @returns The reason, or null while running / between replans
|
|
1550
|
+
*/
|
|
1551
|
+
async stopReasonOf(project, ps, items) {
|
|
1552
|
+
const C = TICKET_AUTOPILOT_CONSTANTS;
|
|
1553
|
+
const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
|
|
1554
|
+
const now = this.now();
|
|
1555
|
+
const nowMs = now.getTime();
|
|
1556
|
+
const allTeams = await this.projectTeams(project, { includePaused: true });
|
|
1557
|
+
const activeTeams = allTeams.filter((t) => !isTeamPausedNow(t));
|
|
1558
|
+
const spent = this.usedToday(allTeams);
|
|
1559
|
+
const budget = this.budgetToday(settings, allTeams).tokens;
|
|
1560
|
+
const { tickets } = await this.deps.tickets.list(project.path);
|
|
1561
|
+
const kinds = new Set([C.TRIAGE_METADATA_KIND, C.REPLAN_METADATA_KIND, C.SELF_REVIEW_METADATA_KIND]);
|
|
1562
|
+
const ttlMs = settings.replanTtlHours * 60 * 60 * 1000;
|
|
1563
|
+
const members = activeTeams.flatMap((t) => t.members ?? []);
|
|
1564
|
+
const idleSessions = new Set(members.filter((m) => isMemberIdle(m)).flatMap((m) => [sessionOf(m), m.sessionName, m.agentId].filter((x) => !!x)));
|
|
1565
|
+
let liveAutopilotItem = false;
|
|
1566
|
+
let stuckDelivery = false;
|
|
1567
|
+
let failedRecently = 0;
|
|
1568
|
+
for (const wi of items) {
|
|
1569
|
+
const md = wi.metadata ?? {};
|
|
1570
|
+
const link = md.projectTicket;
|
|
1571
|
+
const ofProject = md.projectId === project.id || (!!link?.projectPath && !!project.path && path.resolve(link.projectPath) === path.resolve(project.path));
|
|
1572
|
+
if (!ofProject)
|
|
1573
|
+
continue;
|
|
1574
|
+
const age = nowMs - (Date.parse(wi.createdAt) || nowMs);
|
|
1575
|
+
if (typeof md.kind === 'string' && kinds.has(md.kind) && LIVE_STATUSES.has(wi.status)) {
|
|
1576
|
+
// A live triage or replan is the autopilot producing work; a self-review is not (it never masks a stop).
|
|
1577
|
+
const producing = md.kind !== C.SELF_REVIEW_METADATA_KIND;
|
|
1578
|
+
if (producing && !(md.kind === C.REPLAN_METADATA_KIND && age >= ttlMs))
|
|
1579
|
+
liveAutopilotItem = true;
|
|
1580
|
+
// Queued for long while its target sits idle: the wake never landed.
|
|
1581
|
+
if (wi.status === 'queued' && age >= C.STOP_STUCK_DELIVERY_MS && !!wi.target && idleSessions.has(wi.target))
|
|
1582
|
+
stuckDelivery = true;
|
|
1583
|
+
}
|
|
1584
|
+
if (wi.status === 'failed') {
|
|
1585
|
+
const at = Date.parse(wi.statusChangedAt ?? wi.completedAt ?? wi.createdAt) || 0;
|
|
1586
|
+
if (nowMs - at <= C.STOP_SYSTEM_ERROR_LOOKBACK_MS)
|
|
1587
|
+
failedRecently += 1;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const selection = selectTriageCandidates({ tickets, teams: activeTeams, now: nowMs, listed: ps.listed });
|
|
1591
|
+
const waitingOnOwner = tickets.filter((t) => t.status === 'review' ||
|
|
1592
|
+
(OPEN_TICKET_STATUSES.has(t.status) && (hasNeedsOwnerLabel(t) || t.labels.includes(C.RETRO_PENDING_LABEL)))).length;
|
|
1593
|
+
return classifyStopReason({
|
|
1594
|
+
teamsTotal: allTeams.length,
|
|
1595
|
+
teamsActive: activeTeams.length,
|
|
1596
|
+
usedTodayTokens: spent,
|
|
1597
|
+
dailyBudgetTokens: budget,
|
|
1598
|
+
inProgress: tickets.filter((t) => t.status === 'in_progress').length,
|
|
1599
|
+
ready: tickets.filter((t) => t.status === 'ready').length,
|
|
1600
|
+
toTriage: selection.candidates.length,
|
|
1601
|
+
liveAutopilotItem,
|
|
1602
|
+
failedRecently,
|
|
1603
|
+
stuckDelivery,
|
|
1604
|
+
waitingOnOwner,
|
|
1605
|
+
emptyReplanBackoff: replanBackoffHolds(ps.replanBackoff, nowMs, localDateKey(now)),
|
|
1606
|
+
}).reason;
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Classify every enabled project's stop reason and trace each change
|
|
1610
|
+
* (`stopped` with the reason, `resumed` when the work moves again).
|
|
1611
|
+
*
|
|
1612
|
+
* @param projects - Enabled projects
|
|
1613
|
+
*/
|
|
1614
|
+
async processStopReasons(projects) {
|
|
1615
|
+
const state = await this.loadState();
|
|
1616
|
+
const items = await this.deps.pool.getAllItems();
|
|
1617
|
+
const now = this.now();
|
|
1618
|
+
let dirty = false;
|
|
1619
|
+
for (const project of projects) {
|
|
1620
|
+
const ps = (state.projects[project.id] ??= {});
|
|
1621
|
+
let reason;
|
|
1622
|
+
try {
|
|
1623
|
+
reason = await this.stopReasonOf(project, ps, items);
|
|
1624
|
+
}
|
|
1625
|
+
catch (err) {
|
|
1626
|
+
this.logger.warn('Could not classify the autopilot stop reason', { projectId: project.id, error: err instanceof Error ? err.message : String(err) });
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const previous = ps.stop?.reason ?? null;
|
|
1630
|
+
if (reason === previous)
|
|
1631
|
+
continue;
|
|
1632
|
+
dirty = true;
|
|
1633
|
+
if (reason) {
|
|
1634
|
+
ps.stop = { reason, since: now.getTime() };
|
|
1635
|
+
traceAutopilotAction(project, 'stopped', {
|
|
1636
|
+
summary: `${project.name}: autopilot stopped — ${STOP_REASON_WORDS[reason]}`,
|
|
1637
|
+
outcome: 'blocked',
|
|
1638
|
+
data: { reason, ...(previous ? { previous } : {}) },
|
|
1639
|
+
now,
|
|
1640
|
+
});
|
|
1641
|
+
this.logger.info('Ticket autopilot stopped', { projectId: project.id, reason });
|
|
1642
|
+
}
|
|
1643
|
+
else {
|
|
1644
|
+
const since = ps.stop?.since;
|
|
1645
|
+
delete ps.stop;
|
|
1646
|
+
traceAutopilotAction(project, 'resumed', {
|
|
1647
|
+
summary: `${project.name}: autopilot moving again (was: ${previous ? STOP_REASON_WORDS[previous] : 'stopped'})`,
|
|
1648
|
+
outcome: 'ok',
|
|
1649
|
+
data: { previous: previous ?? '', ...(since ? { stoppedMs: now.getTime() - since } : {}) },
|
|
1650
|
+
now,
|
|
1651
|
+
});
|
|
1652
|
+
this.logger.info('Ticket autopilot moving again', { projectId: project.id, previous });
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (dirty)
|
|
1656
|
+
await this.saveState();
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Ask each enabled project's driver for a short self-review at the speed
|
|
1660
|
+
* mode's cadence (Rush hourly, Normal daily, Chill weekly): one live at a
|
|
1661
|
+
* time, never for a paused team (no driver), never over the budget, and
|
|
1662
|
+
* skipped when nothing changed since the last one and nobody is idle.
|
|
1663
|
+
* Projects without a goal get none.
|
|
1664
|
+
*
|
|
1665
|
+
* @param projects - Enabled projects
|
|
1666
|
+
* @returns WorkItems created
|
|
1667
|
+
*/
|
|
1668
|
+
async processSelfReviews(projects) {
|
|
1669
|
+
const C = TICKET_AUTOPILOT_CONSTANTS;
|
|
1670
|
+
if (!this.deps.goalOf)
|
|
1671
|
+
return [];
|
|
1672
|
+
const state = await this.loadState();
|
|
1673
|
+
const now = this.now();
|
|
1674
|
+
const nowMs = now.getTime();
|
|
1675
|
+
const out = [];
|
|
1676
|
+
let items = null;
|
|
1677
|
+
for (const project of projects) {
|
|
1678
|
+
const settings = resolveTicketAutopilotSettings(project.ticketAutopilot);
|
|
1679
|
+
const ps = (state.projects[project.id] ??= {});
|
|
1680
|
+
const ask = ps.selfReviewAsk;
|
|
1681
|
+
// Not due: decided on bookkeeping alone, no reads.
|
|
1682
|
+
if (ask && nowMs - ask.at < settings.selfReviewEveryMs)
|
|
1683
|
+
continue;
|
|
1684
|
+
const teams = await this.projectTeams(project);
|
|
1685
|
+
const driver = this.resolveDriver(settings, teams);
|
|
1686
|
+
const spendTeams = await this.projectTeams(project, { includePaused: true });
|
|
1687
|
+
const spent = this.usedToday(spendTeams);
|
|
1688
|
+
const budget = this.budgetToday(settings, spendTeams).tokens;
|
|
1689
|
+
items ??= await this.deps.pool.getAllItems();
|
|
1690
|
+
let live = false;
|
|
1691
|
+
for (const wi of items) {
|
|
1692
|
+
if (wi.metadata?.kind !== C.SELF_REVIEW_METADATA_KIND || wi.metadata?.projectId !== project.id || !LIVE_STATUSES.has(wi.status))
|
|
1693
|
+
continue;
|
|
1694
|
+
const age = nowMs - (Date.parse(wi.createdAt) || nowMs);
|
|
1695
|
+
if (age < C.SELF_REVIEW_LIVE_MAX_MS)
|
|
1696
|
+
live = true;
|
|
1697
|
+
else if (wi.status === 'queued')
|
|
1698
|
+
await this.deps.pool.cancelQueued(wi.id, 'self-review never picked up; replaced at the next cadence').catch(() => undefined);
|
|
1699
|
+
}
|
|
1700
|
+
const { tickets } = await this.deps.tickets.list(project.path);
|
|
1701
|
+
const latestTicketAt = tickets.reduce((m, t) => Math.max(m, Date.parse(t.updatedAt) || 0), 0);
|
|
1702
|
+
const stopReason = ps.stop?.reason ?? null;
|
|
1703
|
+
let changed = !ask || latestTicketAt > ask.ticketsAt || stopReason !== ask.stopReason;
|
|
1704
|
+
if (!changed && ask && this.deps.goalChangedAt) {
|
|
1705
|
+
const goalAt = await this.deps.goalChangedAt(project).catch(() => null);
|
|
1706
|
+
changed = typeof goalAt === 'number' && goalAt > ask.at;
|
|
1707
|
+
}
|
|
1708
|
+
const members = teams.flatMap((t) => t.members ?? []);
|
|
1709
|
+
const decision = decideSelfReview({
|
|
1710
|
+
enabled: settings.enabled,
|
|
1711
|
+
driver: driver?.session ?? null,
|
|
1712
|
+
now: nowMs,
|
|
1713
|
+
everyMs: settings.selfReviewEveryMs,
|
|
1714
|
+
...(ask ? { lastAskedAt: ask.at } : {}),
|
|
1715
|
+
live,
|
|
1716
|
+
usedTodayTokens: spent,
|
|
1717
|
+
dailyBudgetTokens: budget,
|
|
1718
|
+
changed,
|
|
1719
|
+
anyoneIdle: members.some((m) => isMemberIdle(m)),
|
|
1720
|
+
});
|
|
1721
|
+
if (decision.action === 'skip' || !driver) {
|
|
1722
|
+
this.logger.debug('Autopilot self-review not asked', { projectId: project.id, decision });
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
const goal = await this.deps.goalOf(project, now).catch(() => null);
|
|
1726
|
+
if (!goal || !goal.text.trim()) {
|
|
1727
|
+
// No goal, nothing to review against: try again at the next cadence.
|
|
1728
|
+
ps.selfReviewAsk = { at: nowMs, workItemId: '', ticketsAt: latestTicketAt, stopReason };
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
const since = ask?.at ?? nowMs - 24 * 60 * 60 * 1000;
|
|
1732
|
+
const cadence = settings.selfReviewEveryMs <= 60 * 60 * 1000 ? 'hourly' : settings.selfReviewEveryMs <= 24 * 60 * 60 * 1000 ? 'daily' : 'weekly';
|
|
1733
|
+
const brief = buildSelfReviewBrief({
|
|
1734
|
+
project: { id: project.id, name: project.name },
|
|
1735
|
+
mode: settings.speedMode,
|
|
1736
|
+
cadence,
|
|
1737
|
+
goal: goal.text,
|
|
1738
|
+
closedSince: tickets.filter((t) => (t.status === 'done' || t.status === 'cancelled') && (Date.parse(t.updatedAt) || 0) >= since).length,
|
|
1739
|
+
open: {
|
|
1740
|
+
ready: tickets.filter((t) => t.status === 'ready').length,
|
|
1741
|
+
inProgress: tickets.filter((t) => t.status === 'in_progress').length,
|
|
1742
|
+
backlog: tickets.filter((t) => t.status === 'backlog').length,
|
|
1743
|
+
waitingOnOwner: tickets.filter((t) => t.status === 'review' || (OPEN_TICKET_STATUSES.has(t.status) && hasNeedsOwnerLabel(t))).length,
|
|
1744
|
+
},
|
|
1745
|
+
stopReason,
|
|
1746
|
+
previous: ps.selfReviews?.[ps.selfReviews.length - 1] ?? null,
|
|
1747
|
+
});
|
|
1748
|
+
const workItem = createWorkItem({
|
|
1749
|
+
type: C.SELF_REVIEW_WORK_ITEM_TYPE,
|
|
1750
|
+
owner: 'team_lead',
|
|
1751
|
+
target: driver.session,
|
|
1752
|
+
title: `Self-review: ${project.name}`,
|
|
1753
|
+
description: `Short self-review of ${project.name} against its goal: the gap to the target, what moved it, and the next bet.`,
|
|
1754
|
+
briefMarkdown: capBrief(brief),
|
|
1755
|
+
metadata: {
|
|
1756
|
+
kind: C.SELF_REVIEW_METADATA_KIND,
|
|
1757
|
+
projectId: project.id,
|
|
1758
|
+
projectPath: project.path,
|
|
1759
|
+
teamId: driver.teamId,
|
|
1760
|
+
requiresVerification: false,
|
|
1761
|
+
speedMode: settings.speedMode,
|
|
1762
|
+
},
|
|
1763
|
+
});
|
|
1764
|
+
workItem.createdAt = now.toISOString();
|
|
1765
|
+
workItem.targetSource = 'assigned';
|
|
1766
|
+
await this.deps.pool.addToPool(workItem);
|
|
1767
|
+
ps.selfReviewAsk = { at: nowMs, workItemId: workItem.id, ticketsAt: latestTicketAt, stopReason };
|
|
1768
|
+
traceAutopilotAction(project, 'self_review_scheduled', {
|
|
1769
|
+
summary: `${project.name}: ${driver.session} asked for a ${cadence} self-review (${settings.speedMode})`,
|
|
1770
|
+
outcome: 'queued',
|
|
1771
|
+
workItemId: workItem.id,
|
|
1772
|
+
session: driver.session,
|
|
1773
|
+
data: { speedMode: settings.speedMode, cadence },
|
|
1774
|
+
now,
|
|
1775
|
+
});
|
|
1776
|
+
this.logger.info('Autopilot self-review asked', { projectId: project.id, driver: driver.session, workItemId: workItem.id, speedMode: settings.speedMode });
|
|
1777
|
+
out.push(workItem);
|
|
1778
|
+
}
|
|
1779
|
+
await this.saveState();
|
|
1780
|
+
return out;
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* The driver filed a self-review: kept on the project (newest last,
|
|
1784
|
+
* capped), shown in the status and the digest, and quoted in the next
|
|
1785
|
+
* goal replan brief.
|
|
1786
|
+
*
|
|
1787
|
+
* @param ref - Project id, name or path
|
|
1788
|
+
* @param body - `{ gap, moved?, nextBet }`
|
|
1789
|
+
* @param caller - Owner, orchestrator or a lead of a project team
|
|
1790
|
+
* @returns The stored record
|
|
1791
|
+
* @throws ProjectTicketError(400/403/404)
|
|
1792
|
+
*/
|
|
1793
|
+
async submitSelfReview(ref, body, caller) {
|
|
1794
|
+
const C = TICKET_AUTOPILOT_CONSTANTS;
|
|
1795
|
+
const project = await this.deps.workflow.resolveProject(ref);
|
|
1796
|
+
await this.requireReader(caller, project);
|
|
1797
|
+
const b = (body && typeof body === 'object' ? body : {});
|
|
1798
|
+
const field = (key, required) => {
|
|
1799
|
+
const raw = b[key];
|
|
1800
|
+
const v = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '';
|
|
1801
|
+
if (required && !v)
|
|
1802
|
+
throw new ProjectTicketError(400, `${key} is required`);
|
|
1803
|
+
if (v.length > C.SELF_REVIEW_FIELD_MAX_CHARS)
|
|
1804
|
+
throw new ProjectTicketError(400, `${key} is longer than ${C.SELF_REVIEW_FIELD_MAX_CHARS} characters`);
|
|
1805
|
+
return v;
|
|
1806
|
+
};
|
|
1807
|
+
const now = this.now();
|
|
1808
|
+
const record = {
|
|
1809
|
+
at: now.toISOString(),
|
|
1810
|
+
by: caller.session ?? 'owner',
|
|
1811
|
+
gap: field('gap', true),
|
|
1812
|
+
moved: field('moved', false),
|
|
1813
|
+
nextBet: field('nextBet', true),
|
|
1814
|
+
};
|
|
1815
|
+
const state = await this.loadState();
|
|
1816
|
+
const ps = (state.projects[project.id] ??= {});
|
|
1817
|
+
ps.selfReviews = [...(ps.selfReviews ?? []), record].slice(-C.SELF_REVIEW_HISTORY);
|
|
1818
|
+
await this.saveState();
|
|
1819
|
+
traceAutopilotAction(project, 'self_review_filed', {
|
|
1820
|
+
summary: `${record.by} filed a self-review of ${project.name}: next bet ${record.nextBet.slice(0, 120)}`,
|
|
1821
|
+
outcome: 'ok',
|
|
1822
|
+
...(caller.session ? { session: caller.session, actor: { kind: 'agent', session: caller.session } } : { actor: { kind: 'owner' } }),
|
|
1823
|
+
...(ps.selfReviewAsk?.workItemId ? { workItemId: ps.selfReviewAsk.workItemId } : {}),
|
|
1824
|
+
data: { gap: record.gap.slice(0, 200), nextBet: record.nextBet.slice(0, 200) },
|
|
1825
|
+
now,
|
|
1826
|
+
});
|
|
1827
|
+
this.logger.info('Autopilot self-review filed', { projectId: project.id, by: record.by });
|
|
1828
|
+
return record;
|
|
1829
|
+
}
|
|
1830
|
+
// ---------------------------------------------------------------------------
|
|
1431
1831
|
// Helpers
|
|
1432
1832
|
// ---------------------------------------------------------------------------
|
|
1433
1833
|
/**
|
|
@@ -1465,8 +1865,19 @@ export class TicketAutopilotService {
|
|
|
1465
1865
|
replanInFlight: liveOf(TICKET_AUTOPILOT_CONSTANTS.REPLAN_METADATA_KIND),
|
|
1466
1866
|
replansToday: ps?.replans?.day === today ? ps.replans.count : 0,
|
|
1467
1867
|
lastReplanAt: ps?.replans?.lastAt ? new Date(ps.replans.lastAt).toISOString() : null,
|
|
1468
|
-
replanBackoffUntil: ps?.replanBackoff
|
|
1868
|
+
replanBackoffUntil: replanBackoffHolds(ps?.replanBackoff, nowMs, today) ? (ps?.replanBackoff?.resumeDay ?? null) : null,
|
|
1869
|
+
replanBackoffUntilAt: ps?.replanBackoff && replanBackoffHolds(ps.replanBackoff, nowMs, today)
|
|
1870
|
+
? new Date(ps.replanBackoff.resumeAt ?? new Date(`${ps.replanBackoff.resumeDay}T00:00:00`).getTime()).toISOString()
|
|
1871
|
+
: null,
|
|
1469
1872
|
retroOn: await this.retroOn(project),
|
|
1873
|
+
speedMode: settings.speedMode,
|
|
1874
|
+
stopReason: ps?.stop?.reason ?? null,
|
|
1875
|
+
stopReasonText: ps?.stop ? STOP_REASON_WORDS[ps.stop.reason] : null,
|
|
1876
|
+
stoppedSince: ps?.stop ? new Date(ps.stop.since).toISOString() : null,
|
|
1877
|
+
lastSelfReview: ps?.selfReviews?.[ps.selfReviews.length - 1] ?? null,
|
|
1878
|
+
nextSelfReviewAt: settings.enabled
|
|
1879
|
+
? new Date(ps?.selfReviewAsk ? ps.selfReviewAsk.at + settings.selfReviewEveryMs : nowMs).toISOString()
|
|
1880
|
+
: null,
|
|
1470
1881
|
};
|
|
1471
1882
|
}
|
|
1472
1883
|
/**
|