mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
- src/web/settings-writer.ts:110: export assertSettingsWritable(), the writer's own read-for-write rule run on its own - src/web/routes/case-routes.ts:1860: an admin's rename checks settings.json before it ends any session or moves the folder, and answers 409 CONFLICT with the writer's message (placed before the session-starting check, so nothing is awaited between it and the lock) - test/routes/case-rename-routes.test.ts:314: the torn-file pin now expects 409 CONFLICT (no stoppedSessions, like the other pre-checks) - test/routes/case-rename-routes.test.ts:331: new test, a live session with stopSessions and a torn file: 409 CONFLICT, cleanupSession not called - docs/api-reference.md: the Settings paragraph says an admin's rename answers this refusal as 409 CONFLICT before ending sessions - src/hooks-config.ts:1161, src/prompt-cache-keepwarm.ts:341: comments describe the atomic writer, not an in-place rewrite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
441 lines
21 KiB
TypeScript
441 lines
21 KiB
TypeScript
/**
|
|
* @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle
|
|
* claude session's prompt cache would expire, so the next real prompt reads the conversation
|
|
* from cache instead of re-processing it at the cache-write rate.
|
|
*
|
|
* Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and
|
|
* lets it expire after an hour idle. Measured on real sessions, nearly every gap over an
|
|
* hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn
|
|
* costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the
|
|
* payoff is concentrated in gaps of one to four hours, which is why there is a cap after the
|
|
* last real prompt rather than an indefinite loop.
|
|
*
|
|
* Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm
|
|
* on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own
|
|
* idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace
|
|
* the conversation with a summary first. That is decided once per launch in
|
|
* `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session
|
|
* switched on after launch is kept warm best-effort and says so.
|
|
*
|
|
* Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object,
|
|
* see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing
|
|
* here infers the lifetime from idle detection or the plan tier.
|
|
*
|
|
* Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a
|
|
* table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs
|
|
* from the server. It never touches the session except through `beginKeepAliveTurn()` and
|
|
* `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the
|
|
* real-prompt clock and opens its stop hook and idle prompt pre-acknowledged
|
|
* (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`.
|
|
*
|
|
* @module prompt-cache-keepwarm
|
|
*/
|
|
|
|
import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js';
|
|
|
|
/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */
|
|
export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".';
|
|
/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */
|
|
export const KEEP_WARM_LEAD_MS = 5 * 60_000;
|
|
/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */
|
|
export const KEEP_WARM_RETRY_MS = 60_000;
|
|
/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */
|
|
export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
|
|
/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */
|
|
export const KEEP_WARM_MIN_TOKENS = 20_000;
|
|
/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */
|
|
export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000;
|
|
/**
|
|
* Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt
|
|
* (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a
|
|
* short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle
|
|
* prompt never arrives, such as a workspace without Codeman's hooks.
|
|
*/
|
|
export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000;
|
|
/** Default cap: hours after the last real prompt during which nudges are sent. */
|
|
export const DEFAULT_KEEP_WARM_MAX_HOURS = 2;
|
|
/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */
|
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
|
|
/**
|
|
* Whether keep-warm is on for a session: its own choice, else the app-wide default. A session
|
|
* an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished
|
|
* its task is idle exactly like one that will be prompted again, and most never are, so only
|
|
* its own switch keeps it warm. One function because the launch (the `idleCompaction`
|
|
* opt-out) and the service must agree.
|
|
*/
|
|
export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean {
|
|
return choice ?? (appDefault && !spawnedByAgent);
|
|
}
|
|
|
|
export interface KeepWarmFacts {
|
|
now: number;
|
|
/** The session's own choice; undefined = the app-wide default applies. */
|
|
choice: boolean | undefined;
|
|
appDefault: boolean;
|
|
/** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */
|
|
spawnedByAgent: boolean;
|
|
/** Whether this launch carries the statusLine exporter; undefined when unknown. */
|
|
statusLine?: StatusLineExporter;
|
|
cache: PromptCacheStatus | null | undefined;
|
|
/** Idle by Codeman's own probe and not mid-turn. */
|
|
idle: boolean;
|
|
/** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */
|
|
paneAlive: boolean;
|
|
/** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */
|
|
limitPaused: boolean;
|
|
/** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */
|
|
automationActive: boolean;
|
|
/** A permission, question or elicitation dialog is open (never the idle item). */
|
|
pendingApproval: boolean;
|
|
/** The composer line holds no text; null when the pane could not be read. */
|
|
composerEmpty: boolean | null;
|
|
/** Last keystroke a person sent, epoch ms; 0 when none seen. */
|
|
lastHumanInputAt: number;
|
|
/** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */
|
|
budgetAnchorAt: number;
|
|
maxKeepWarmMs: number;
|
|
}
|
|
|
|
export type KeepWarmDecision =
|
|
| { action: 'nudge' }
|
|
| { action: 'wait'; until: number; reason: string }
|
|
| { action: 'stop'; reason: string };
|
|
|
|
const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason });
|
|
const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason });
|
|
|
|
/**
|
|
* The rulebook. Hard stops first (nothing to do until the next cache report or a flag
|
|
* change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS`
|
|
* and never past the last-chance line. Pure: `now` is passed in.
|
|
*/
|
|
export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
|
|
if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) {
|
|
return stop(
|
|
f.choice === undefined && f.appDefault
|
|
? 'an agent-spawned session follows the app-wide default only when switched on for itself'
|
|
: 'off'
|
|
);
|
|
}
|
|
// Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the
|
|
// cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry.
|
|
if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`);
|
|
const c = f.cache;
|
|
if (!c) return stop('no cache report yet');
|
|
if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it');
|
|
// Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without
|
|
// the opt-out): the full context is already gone, so keeping the summary warm buys little.
|
|
// The stamp is dropped at the next request, which re-opens the next gap.
|
|
if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt');
|
|
if (c.ttl === '5m') {
|
|
return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents');
|
|
}
|
|
if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn');
|
|
if (!f.paneAlive) return stop('no live pane');
|
|
if (f.limitPaused) return stop('usage-limit pause');
|
|
if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed');
|
|
const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS;
|
|
if (f.now < dueAt) return wait(dueAt, 'not due yet');
|
|
const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS;
|
|
if (f.now >= lastChance) return stop('too close to expiry to land a turn');
|
|
const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance);
|
|
if (!f.idle) return wait(retryAt, 'session is working');
|
|
if (f.pendingApproval) return wait(retryAt, 'a dialog is open');
|
|
// Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends
|
|
// without a turn, and so does a capture that failed once.
|
|
if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session');
|
|
if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer');
|
|
if (!f.composerEmpty) return wait(retryAt, 'the composer holds text');
|
|
if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) {
|
|
return wait(retryAt, 'someone typed a moment ago');
|
|
}
|
|
return { action: 'nudge' };
|
|
}
|
|
|
|
/**
|
|
* Whether a session could be nudged once the settings are known: its own switch is not off, its
|
|
* launch reports the cache, and the last report is warm. Only such a session has anything to
|
|
* lose while the settings cannot be read, so only it retries on its own.
|
|
*/
|
|
function couldNudge(s: KeepWarmSessionView): boolean {
|
|
return (
|
|
s.keepWarmEnabled !== false &&
|
|
s.statusLineExporter === 'injected' &&
|
|
!!s.promptCache?.warm &&
|
|
!!s.promptCache.expiresAt
|
|
);
|
|
}
|
|
|
|
/** What the service needs from a session; `Session` satisfies it structurally. */
|
|
export interface KeepWarmSessionView {
|
|
readonly id: string;
|
|
readonly createdAt: number;
|
|
readonly pid: number | null;
|
|
/** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */
|
|
readonly paneExit?: unknown;
|
|
readonly parentSessionId?: string;
|
|
readonly promptCache: PromptCacheStatus | null;
|
|
readonly statusLineExporter?: StatusLineExporter;
|
|
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
|
|
readonly idleCompactionOptOut: boolean;
|
|
readonly keepWarmEnabled: boolean | undefined;
|
|
readonly isWorking: boolean;
|
|
readonly isLimitPaused: boolean;
|
|
readonly lastHumanInputAt: number;
|
|
/** Last submit that was not the keep-alive's own (persisted): the cap's clock. */
|
|
readonly lastRealPromptAt: number;
|
|
isIdle(): boolean;
|
|
beginKeepAliveTurn(windowMs: number): void;
|
|
/** Close the window `beginKeepAliveTurn()` opened, when the nudge never went out. */
|
|
endKeepAliveTurn(): void;
|
|
writeViaMux(data: string): Promise<boolean>;
|
|
}
|
|
|
|
export interface KeepWarmSettings {
|
|
/** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */
|
|
keepWarmDefault: boolean;
|
|
/** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */
|
|
maxHours: number;
|
|
}
|
|
|
|
export interface KeepWarmPorts {
|
|
getSession(id: string): KeepWarmSessionView | undefined;
|
|
sessionIds(): Iterable<string>;
|
|
readSettings(): Promise<KeepWarmSettings>;
|
|
isAutomationActive(id: string): boolean;
|
|
hasPendingApproval(id: string): boolean;
|
|
/**
|
|
* Read the pane once a nudge is otherwise due: whether the composer is empty (null when it
|
|
* cannot be read), and whether a usage-limit message with a reset still ahead is on screen
|
|
* (auto-resume's own pause tracking only runs while auto-resume is on).
|
|
*/
|
|
readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean };
|
|
/** Store and broadcast the status (the server dedupes against the session's last one). */
|
|
publish(id: string, status: KeepWarmStatus): void;
|
|
log?(message: string): void;
|
|
}
|
|
|
|
/** The scheduler the routes see: one method per trigger, nothing else. */
|
|
export interface KeepWarmScheduler {
|
|
/** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */
|
|
schedule(sessionId: string): Promise<void>;
|
|
/** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */
|
|
rescheduleAll(): void;
|
|
/** The session is gone. */
|
|
remove(sessionId: string): void;
|
|
}
|
|
|
|
export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
|
private readonly timers = new Map<string, NodeJS.Timeout>();
|
|
private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>();
|
|
/** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */
|
|
private readonly inFlight = new Map<string, symbol>();
|
|
/** The evaluation running per session, and the one follow-up queued behind it. */
|
|
private readonly running = new Map<string, Promise<void>>();
|
|
private readonly followUps = new Map<string, Promise<void>>();
|
|
/**
|
|
* The default and cap, read once and kept: every statusline report schedules an evaluation,
|
|
* and reading settings.json on each would cost every install, the feature off included.
|
|
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
|
|
*/
|
|
private settings: Promise<KeepWarmSettings> | undefined;
|
|
/** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */
|
|
private settingsFailure: string | undefined;
|
|
private stopped = false;
|
|
|
|
constructor(private readonly ports: KeepWarmPorts) {}
|
|
|
|
schedule(sessionId: string): Promise<void> {
|
|
if (this.stopped) return Promise.resolve();
|
|
const running = this.running.get(sessionId);
|
|
if (running) {
|
|
// A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change)
|
|
// carries what that evaluation read too early, so one follow-up runs after it, shared by
|
|
// every trigger that arrives meanwhile.
|
|
let next = this.followUps.get(sessionId);
|
|
if (!next) {
|
|
next = running.then(() => {
|
|
// `remove()` drops the queued follow-up: a deleted session gets no evaluation.
|
|
if (this.followUps.get(sessionId) !== next) return;
|
|
this.followUps.delete(sessionId);
|
|
return this.schedule(sessionId);
|
|
});
|
|
this.followUps.set(sessionId, next);
|
|
}
|
|
return next;
|
|
}
|
|
const run = this.evaluate(sessionId)
|
|
.catch((err) => {
|
|
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
// A failed write or pane read is transient, and an idle session gets no other trigger
|
|
// until its next turn. (A failed settings read is handled in evaluate().)
|
|
// Not after `remove()`, which drops this run: the session is being deleted.
|
|
if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS);
|
|
})
|
|
.finally(() => {
|
|
if (this.running.get(sessionId) === run) this.running.delete(sessionId);
|
|
});
|
|
this.running.set(sessionId, run);
|
|
return run;
|
|
}
|
|
|
|
rescheduleAll(): void {
|
|
this.settings = undefined;
|
|
for (const id of this.ports.sessionIds()) void this.schedule(id);
|
|
}
|
|
|
|
remove(sessionId: string): void {
|
|
this.clearTimer(sessionId);
|
|
this.tallies.delete(sessionId);
|
|
this.inFlight.delete(sessionId);
|
|
this.running.delete(sessionId);
|
|
this.followUps.delete(sessionId);
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped = true;
|
|
for (const id of [...this.timers.keys()]) this.clearTimer(id);
|
|
}
|
|
|
|
private readSettings(): Promise<KeepWarmSettings> {
|
|
if (!this.settings) {
|
|
const read = this.ports.readSettings();
|
|
this.settings = read;
|
|
// A failed read is not kept: the next evaluation tries again.
|
|
read.catch(() => {
|
|
if (this.settings === read) this.settings = undefined;
|
|
});
|
|
}
|
|
return this.settings;
|
|
}
|
|
|
|
private clearTimer(id: string): void {
|
|
const t = this.timers.get(id);
|
|
if (t) clearTimeout(t);
|
|
this.timers.delete(id);
|
|
}
|
|
|
|
private arm(id: string, at: number): void {
|
|
this.clearTimer(id);
|
|
const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS));
|
|
t.unref?.();
|
|
this.timers.set(id, t);
|
|
}
|
|
|
|
private async evaluate(id: string): Promise<void> {
|
|
if (this.stopped || this.inFlight.has(id)) return;
|
|
const token = Symbol(id);
|
|
this.inFlight.set(id, token);
|
|
// A session removed (or the service stopped) during an await gets nothing after it: no
|
|
// nudge, no tally, no timer, no status.
|
|
const cancelled = () => this.stopped || this.inFlight.get(id) !== token;
|
|
try {
|
|
this.clearTimer(id);
|
|
const session = this.ports.getSession(id);
|
|
if (!session) return;
|
|
let settings: KeepWarmSettings;
|
|
try {
|
|
settings = await this.readSettings();
|
|
} catch (err) {
|
|
// A failed read is retried (settings.json is replaced atomically, so a read never sees a
|
|
// half-written file; a failure is a read error or a file that does not parse). A settings.json
|
|
// that stays broken (a hand edit with a trailing comma) fails every read until it is fixed, so
|
|
// it is logged once rather than once a minute per session, and only a session that could
|
|
// nudge retries.
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message !== this.settingsFailure) {
|
|
this.settingsFailure = message;
|
|
this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`);
|
|
}
|
|
if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS);
|
|
return;
|
|
}
|
|
this.settingsFailure = undefined;
|
|
if (cancelled()) return;
|
|
const tally = this.tallies.get(id) ?? { nudges: 0 };
|
|
const facts: KeepWarmFacts = {
|
|
now: Date.now(),
|
|
choice: session.keepWarmEnabled,
|
|
appDefault: settings.keepWarmDefault,
|
|
spawnedByAgent: !!session.parentSessionId,
|
|
statusLine: session.statusLineExporter,
|
|
cache: session.promptCache,
|
|
idle: session.isIdle() && !session.isWorking,
|
|
paneAlive: session.pid !== null && !session.paneExit,
|
|
limitPaused: session.isLimitPaused,
|
|
automationActive: this.ports.isAutomationActive(id),
|
|
pendingApproval: this.ports.hasPendingApproval(id),
|
|
// Optimistic until everything else says "due": the pane read is the one costly fact.
|
|
composerEmpty: true,
|
|
lastHumanInputAt: session.lastHumanInputAt,
|
|
budgetAnchorAt: session.lastRealPromptAt || session.createdAt,
|
|
maxKeepWarmMs: settings.maxHours * 3_600_000,
|
|
};
|
|
let decision = decideKeepWarm(facts);
|
|
if (decision.action === 'nudge') {
|
|
const pane = this.ports.readPane(session);
|
|
decision = decideKeepWarm({
|
|
...facts,
|
|
composerEmpty: pane.composerEmpty,
|
|
limitPaused: facts.limitPaused || pane.limitShown,
|
|
});
|
|
}
|
|
const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent);
|
|
const base = {
|
|
enabled,
|
|
idleCompactionOff: session.idleCompactionOptOut,
|
|
nudges: tally.nudges,
|
|
lastNudgeAt: tally.lastNudgeAt,
|
|
// Off, the window means nothing, and it moves with every real prompt: carrying it would
|
|
// broadcast a change on every turn of every session.
|
|
...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }),
|
|
};
|
|
if (decision.action === 'nudge') {
|
|
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
|
|
const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`);
|
|
if (cancelled()) return;
|
|
if (ok) {
|
|
tally.nudges += 1;
|
|
tally.lastNudgeAt = Date.now();
|
|
this.tallies.set(id, tally);
|
|
}
|
|
this.ports.log?.(
|
|
`[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})`
|
|
);
|
|
// Sent: the nudge's own cache report re-schedules, and this timer is the fallback if none
|
|
// arrives. Not sent: no turn is running, so the window closes now rather than at its
|
|
// ceiling, and the retry lands inside the last minutes before the expiry, where a nudge
|
|
// can still go out (a lead time later would be past it).
|
|
let nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS;
|
|
if (!ok) {
|
|
session.endKeepAliveTurn();
|
|
const expiresAt = facts.cache?.expiresAt ?? 0;
|
|
nextCheckAt = Math.min(Date.now() + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS);
|
|
}
|
|
this.arm(id, nextCheckAt);
|
|
this.ports.publish(id, {
|
|
...base,
|
|
nudges: tally.nudges,
|
|
lastNudgeAt: tally.lastNudgeAt,
|
|
state: ok ? 'nudged' : 'waiting',
|
|
nextCheckAt,
|
|
reason: ok ? undefined : 'the keep-alive could not be written',
|
|
});
|
|
} else if (decision.action === 'wait') {
|
|
this.arm(id, decision.until);
|
|
this.ports.publish(id, {
|
|
...base,
|
|
state: decision.reason === 'not due yet' ? 'armed' : 'waiting',
|
|
nextCheckAt: decision.until,
|
|
reason: decision.reason === 'not due yet' ? undefined : decision.reason,
|
|
});
|
|
} else {
|
|
this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason });
|
|
}
|
|
} finally {
|
|
if (this.inFlight.get(id) === token) this.inFlight.delete(id);
|
|
}
|
|
}
|
|
}
|