@ours.network/fleet 1.2.0-nightly.64 → 1.2.0-nightly.66

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.
@@ -0,0 +1,108 @@
1
+ import { z } from 'zod';
2
+ import { ReminderError } from './store.js';
3
+ export const REMINDER_TOOL_NAMES = Object.freeze([
4
+ 'fleet_reminder_create', 'fleet_reminder_list', 'fleet_reminder_update',
5
+ 'fleet_reminder_cancel', 'fleet_reminder_inspect', 'fleet_reminder_ack',
6
+ ]);
7
+ const schedule = {
8
+ local_time: z.string().describe('Calendar date and wall-clock time WITHOUT an offset, e.g. "2027-01-20T16:00". Always a real date: resolve "tomorrow" yourself.'),
9
+ time_zone: z.string().describe('IANA zone the time is meant in, e.g. "Europe/London", "America/New_York" or "UTC". Use the zone of the person or task, not a guess.'),
10
+ fold: z.union([z.literal(0), z.literal(1)]).optional().describe('Only for a wall time that occurs twice when clocks go back: 0 = first occurrence, 1 = second.'),
11
+ late_window_seconds: z.number().int().optional().describe('How late the wake may still be delivered if this agent or the host was offline or busy at the due time (60..604800, default 3600). After it the reminder expires without a wake.'),
12
+ };
13
+ function result(value) {
14
+ return {
15
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
16
+ structuredContent: value,
17
+ isError: false,
18
+ };
19
+ }
20
+ function failure(code, message) {
21
+ const value = { error: code, message };
22
+ return { content: [{ type: 'text', text: JSON.stringify(value) }], isError: true };
23
+ }
24
+ /**
25
+ * Register the self-reminder tools on the agent's own fixed-identity MCP
26
+ * server. No argument names an agent, identity or recipient: the server is the
27
+ * caller's own supervisor, so every operation is implicitly self-scoped.
28
+ */
29
+ export function registerReminderTools(server, backend,
30
+ /** The endpoint's runtime admission; nothing is read or changed unless it admits. */
31
+ admit) {
32
+ const run = async (operation, mutates) => {
33
+ let release;
34
+ try {
35
+ release = await admit();
36
+ }
37
+ catch {
38
+ return failure('runtime_not_ready', 'the managed runtime is not admitting tool calls right now; nothing was read or changed');
39
+ }
40
+ try {
41
+ const target = backend();
42
+ if (!target)
43
+ return failure('reminders_unavailable', 'self-reminders are not available in this session (the supervisor has not bound them, or this session cannot take scheduled prompts)');
44
+ try {
45
+ const value = operation(target);
46
+ if (mutates)
47
+ target.changed();
48
+ return result(value);
49
+ }
50
+ catch (error) {
51
+ if (error instanceof ReminderError)
52
+ return failure(error.code, error.message);
53
+ return failure('reminder_store_failed', 'the reminder could not be recorded; nothing was changed');
54
+ }
55
+ }
56
+ finally {
57
+ release();
58
+ }
59
+ };
60
+ server.registerTool('fleet_reminder_create', {
61
+ description: 'Schedule a one-time reminder that wakes YOU (this agent) at a future date and time. '
62
+ + 'It sends nothing to anyone: when due, Fleet starts a turn for you with the reminder, and you decide what to do. '
63
+ + 'Use it to come back to something later instead of polling or waiting. Repeating a call with the same request_key '
64
+ + 'and input returns the existing reminder instead of creating another.',
65
+ inputSchema: {
66
+ request_key: z.string().describe('Stable key for this scheduling decision, so a retried call cannot create a duplicate (e.g. "release-check-2027-01-20").'),
67
+ title: z.string().describe('Short label shown in the wake (max 200 characters).'),
68
+ note: z.string().optional().describe('Private context for your future self: what to check and why (max 4000 characters). Returned by inspect, not in the wake text.'),
69
+ ...schedule,
70
+ },
71
+ }, async (args) => run(({ store }) => store.create(args.request_key, args), true));
72
+ server.registerTool('fleet_reminder_list', {
73
+ description: 'List your own reminders. state: "open" (default: scheduled or delivered but not acknowledged), "closed", or "all". '
74
+ + 'Pages with limit (1..100, default 50) and after (the id from the previous page).',
75
+ inputSchema: {
76
+ state: z.enum(['open', 'closed', 'all']).optional(),
77
+ limit: z.number().int().optional(),
78
+ after: z.string().optional(),
79
+ },
80
+ }, async (args) => run(({ store }) => store.list(args), false));
81
+ server.registerTool('fleet_reminder_update', {
82
+ description: 'Change one of your reminders, e.g. to postpone it. Pass the id and the revision you last saw '
83
+ + '(expected_revision) plus only the fields to change. Any wake already issued for the old revision becomes void.',
84
+ inputSchema: {
85
+ id: z.string(),
86
+ expected_revision: z.number().int(),
87
+ title: z.string().optional(),
88
+ note: z.string().optional(),
89
+ local_time: schedule.local_time.optional(),
90
+ time_zone: schedule.time_zone.optional(),
91
+ fold: schedule.fold,
92
+ late_window_seconds: schedule.late_window_seconds,
93
+ },
94
+ }, async ({ id, expected_revision, ...changes }) => run(({ store }) => store.update(id, expected_revision, changes), true));
95
+ server.registerTool('fleet_reminder_cancel', {
96
+ description: 'Cancel one of your reminders (id + expected_revision). A wake already waiting for it will not be delivered.',
97
+ inputSchema: { id: z.string(), expected_revision: z.number().int() },
98
+ }, async ({ id, expected_revision }) => run(({ store }) => store.cancel(id, expected_revision), true));
99
+ server.registerTool('fleet_reminder_inspect', {
100
+ description: 'Read the reminder behind a [fleet-reminder] wake, including its note, and whether the wake is still current and eligible.',
101
+ inputSchema: { event_id: z.string() },
102
+ }, async ({ event_id }) => run(({ store }) => store.inspect(event_id), false));
103
+ server.registerTool('fleet_reminder_ack', {
104
+ description: 'Record that you handled a [fleet-reminder] wake (event_id + revision from the wake). '
105
+ + 'It closes the reminder and sends nothing; it does not claim any external action happened. Safe to repeat.',
106
+ inputSchema: { event_id: z.string(), expected_revision: z.number().int() },
107
+ }, async ({ event_id, expected_revision }) => run(({ store }) => store.ack(event_id, expected_revision), true));
108
+ }
package/dist/runner.d.ts CHANGED
@@ -4,7 +4,7 @@ import { probeIdentityPresence, type MonitorHandle, type MonitorOpts, type Fetch
4
4
  import { type DaemonGenerationProbe } from './daemon-recovery.js';
5
5
  import { type Exec } from './exec.js';
6
6
  import { RoleControlServer } from './session/control.js';
7
- import type { AgentSession, ExitRecord, TurnResult } from './session/types.js';
7
+ import { type AgentSession, type ExitRecord, type TurnResult } from './session/types.js';
8
8
  import type { AgentSessionAdapter, AgentSessionStartOptions } from './harness/agent-session.js';
9
9
  import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
10
10
  import { type OwnerBinderLease } from './owner-channel/binder.js';
package/dist/runner.js CHANGED
@@ -26,6 +26,8 @@ import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
26
26
  import { resolveLaunchRuntime } from './isolation/runtime.js';
27
27
  import { controlRequest, RoleControlServer } from './session/control.js';
28
28
  import { ACP_CANCEL_DEADLINE_EXCEEDED, CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED, classifyShellStatus, } from './session/types.js';
29
+ import { sessionBackendCapabilities } from './session/types.js';
30
+ import { bindRoleReminders } from './reminders/runtime.js';
29
31
  import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
30
32
  import { rotateWorklog } from './worklog.js';
31
33
  import { reconcilePermanentRoleIdentities } from './creation.js';
@@ -612,6 +614,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
612
614
  const managedService = await deps.prepareAgentOurs(role, dir, opts.identityLifetime ? opts.identityLifetime === 'temporary' : temp);
613
615
  startupTelemetry.mark('identity_ready');
614
616
  let layoutRetiring = false;
617
+ // Self-reminders live and die with this attempt's runtime binding: every exit
618
+ // path below stops the dispatcher before that binding is released.
619
+ let reminderManager;
615
620
  try {
616
621
  const managedHarness = prepareManagedHarness(role, dir, runCwd, managedService.descriptor, { ...harnessChildEnv(role, launch.env, dir), ...subscription?.env });
617
622
  // Start-stagger: space this launch at least start_stagger_ms after the previous
@@ -680,6 +685,10 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
680
685
  monitorStarted = true;
681
686
  monitorLoop = monitor?.run(pid);
682
687
  }
688
+ // Reminder wakes follow the same gate as mail wakes: never into a role
689
+ // whose protected startup turn has not succeeded.
690
+ if (monitorMayStart && sessionStartupComplete && !deps.shouldStop?.())
691
+ reminderManager?.start();
683
692
  };
684
693
  let sessionClosed = false;
685
694
  let ownerChannel;
@@ -713,6 +722,16 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
713
722
  pid = agentSession.pid;
714
723
  arbiter = new RoleTurnArbiter(agentSession);
715
724
  sessionHandle = arbiter;
725
+ reminderManager = bindRoleReminders({
726
+ role: name, stateDir: dir, runtime: managedService.setReminders ? managedService.runtime.snapshot : {},
727
+ promptInput: sessionBackendCapabilities(sessionBackend, role.harness).promptInput,
728
+ admitter: arbiter, service: managedService,
729
+ deps: {
730
+ now: deps.now, log: deps.log,
731
+ setTimer: (callback, ms) => setTimeout(callback, ms),
732
+ clearTimer: timer => clearTimeout(timer),
733
+ },
734
+ });
716
735
  const unsubscribeFailure = agentSession.subscribe(event => {
717
736
  if (event.kind !== 'error' || !event.text || event.origin?.kind === 'stall-watchdog')
718
737
  return;
@@ -1105,6 +1124,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1105
1124
  break;
1106
1125
  }
1107
1126
  if (deps.shouldStop?.()) {
1127
+ reminderManager?.stop();
1108
1128
  if (temp) {
1109
1129
  retirementReason = requestedTempStopReason(dir) ?? 'supervisor-signal';
1110
1130
  deps.log(`[${name}] temporary supervisor retirement requested (${retirementReason})`);
@@ -1130,6 +1150,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1130
1150
  if (now - identityAbsentSince >= TEMP_IDENTITY_CLOSE_DEBOUNCE_MS) {
1131
1151
  retirementReason = 'identity-closed';
1132
1152
  deps.log(`[${name}] temporary identity '${role.identity}' closed; retiring session and supervisor`);
1153
+ reminderManager?.stop();
1133
1154
  await sessionHandle.close();
1134
1155
  sessionClosed = true;
1135
1156
  break;
@@ -1153,6 +1174,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1153
1174
  }
1154
1175
  }
1155
1176
  monitorMayStart = false;
1177
+ reminderManager?.stop();
1156
1178
  if (loopManager) {
1157
1179
  control?.setLoopManager(undefined);
1158
1180
  await loopManager.stop();
@@ -1238,6 +1260,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1238
1260
  };
1239
1261
  }
1240
1262
  finally {
1263
+ reminderManager?.stop();
1264
+ managedService.setReminders?.(undefined);
1241
1265
  await managedService.close(layoutRetiring);
1242
1266
  }
1243
1267
  }
@@ -9,7 +9,7 @@ import * as acp from '@agentclientprotocol/sdk';
9
9
  import { normalizeSessionUpdate } from './conversation-normalizer.js';
10
10
  import { ConversationEventStore } from './conversation-store.js';
11
11
  import { SessionEvents } from './events.js';
12
- import { commandName, memberAuthority, memberBindings, pendingInventory, takeBatch, } from './prompt-batch.js';
12
+ import { commandName, memberAuthority, memberBindings, pendingInventory, takeBatch, refuseAtRelease, RELEASE_GUARD_REFUSED, } from './prompt-batch.js';
13
13
  import { recordClaudeRateLimit } from '../subscriptions/observed.js';
14
14
  import { DEFAULT_STALL_TIMEOUT_MS, STALL_RECOVERY_PROMPT, StallWatchdog, StallToolHistory, hasStallRecoveryClaim } from './stall-watchdog.js';
15
15
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, sessionBackendCapabilities, turnResult, } from './types.js';
@@ -167,6 +167,9 @@ function conversationSource(origin) {
167
167
  case 'owner': return { source: 'owner_channel', persistBody: false };
168
168
  case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
169
169
  case 'scheduled-loop': return { source: 'scheduled_loop', persistBody: false };
170
+ // A supervisor wake like a monitor notice; the body is never retained, so
171
+ // restart recovery can never replay a reminder past its dispatch fence.
172
+ case 'self-reminder': return { source: 'fleet_monitor', persistBody: false };
170
173
  case 'local-console':
171
174
  default:
172
175
  return { source: 'local_console', persistBody: true };
@@ -702,6 +705,9 @@ export class AcpSession {
702
705
  * for it is what turned a busy agent into a timeout and then into "dead".
703
706
  */
704
707
  async queuePrompt(text, options = {}) {
708
+ // A guarded prompt must reach the model only through the release fence.
709
+ if (options.releaseGuard)
710
+ options = { ...options, interrupt: false, steer: false };
705
711
  if (this.stallRecoveryClaimed && options.origin?.kind === 'fleet-monitor')
706
712
  options = { ...options, interrupt: false, steer: false };
707
713
  if (this.cancelRecoveryReason)
@@ -727,7 +733,7 @@ export class AcpSession {
727
733
  const run = this.observation.checkpoint(() => {
728
734
  this.admitToLedger(promptId, text, queuedBehind, options);
729
735
  this.queueDepth++;
730
- return this.enqueue(promptId, text, options.origin);
736
+ return this.enqueue(promptId, text, options.origin, options.releaseGuard);
731
737
  });
732
738
  const completion = run.then(result => {
733
739
  this.queueDepth = Math.max(0, this.queueDepth - 1);
@@ -752,10 +758,11 @@ export class AcpSession {
752
758
  * own step in the chain, so its place in line is unchanged; what the step
753
759
  * does when it is reached is release a whole snapshot, not one prompt.
754
760
  */
755
- enqueue(promptId, text, origin) {
761
+ enqueue(promptId, text, origin, releaseGuard) {
756
762
  const entry = {
757
763
  promptId, text, origin, ...conversationSource(origin), admittedAt: new Date().toISOString(),
758
764
  command: this.advertisedCommands.has(commandName(text) ?? ''),
765
+ ...(releaseGuard ? { releaseGuard } : {}),
759
766
  };
760
767
  this.pendingPrompts.push(entry);
761
768
  const run = this.promptTail.then(() => this.release(entry));
@@ -772,6 +779,16 @@ export class AcpSession {
772
779
  release(entry) {
773
780
  if (entry.released)
774
781
  return entry.released;
782
+ const refused = refuseAtRelease(this.pendingPrompts, entry, Boolean(this.sessionId && this.isAlive()));
783
+ if (refused) {
784
+ this.conversation.appendSafe({
785
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration, acpSessionId: this.sessionId,
786
+ promptId: entry.promptId, turnId: entry.promptId,
787
+ payload: { outcome: 'cancelled', stopReason: RELEASE_GUARD_REFUSED },
788
+ });
789
+ entry.released = Promise.resolve(refused);
790
+ return entry.released;
791
+ }
775
792
  const members = takeBatch(this.pendingPrompts, entry);
776
793
  for (const member of members)
777
794
  this.sharedTurns.set(member.promptId, members[0].promptId);
@@ -1,5 +1,17 @@
1
1
  import type { ConversationHandlePage, ExitRecord, InterruptOutcome, PromptOrigin, QueuedPrompt, SessionEvent, AgentSession, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
2
2
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
3
+ export type ReminderAttempt =
4
+ /** Admitted; `queued.completion` settles when the turn (or its refusal) ends. */
5
+ {
6
+ state: 'admitted';
7
+ queued: QueuedPrompt;
8
+ } | {
9
+ state: 'busy';
10
+ } | {
11
+ state: 'unavailable';
12
+ error: string;
13
+ admitted: false;
14
+ };
3
15
  export type ScheduledAttempt = {
4
16
  state: 'started';
5
17
  queued: QueuedPrompt;
@@ -68,6 +80,17 @@ export declare class RoleTurnArbiter implements AgentSession {
68
80
  tryScheduled(text: string, origin: Extract<PromptOrigin, {
69
81
  kind: 'scheduled-loop';
70
82
  }>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
83
+ /**
84
+ * Admit a self-reminder wake only into an idle role, at the same boundary
85
+ * every producer uses. It never interrupts or steers, and never joins a
86
+ * queue behind other work: a busy role answers `busy` and the caller tries
87
+ * again later. `beforeQueue` makes the caller's durable claim; if it
88
+ * declines, nothing is queued. `releaseGuard` is the session's last-moment
89
+ * fence and is the only way the prompt can reach the model.
90
+ */
91
+ tryReminder(text: string, origin: Extract<PromptOrigin, {
92
+ kind: 'self-reminder';
93
+ }>, beforeQueue: () => boolean, releaseGuard: () => boolean): Promise<ReminderAttempt>;
71
94
  /** Retire only at the same admission boundary used by all prompt producers. */
72
95
  retireIfIdle(expired: (snapshot: SessionSnapshot, queueDepth: number) => boolean): Promise<boolean>;
73
96
  sharedTurnId(promptId: string): string | undefined;
@@ -154,6 +154,39 @@ export class RoleTurnArbiter {
154
154
  throw error;
155
155
  });
156
156
  }
157
+ /**
158
+ * Admit a self-reminder wake only into an idle role, at the same boundary
159
+ * every producer uses. It never interrupts or steers, and never joins a
160
+ * queue behind other work: a busy role answers `busy` and the caller tries
161
+ * again later. `beforeQueue` makes the caller's durable claim; if it
162
+ * declines, nothing is queued. `releaseGuard` is the session's last-moment
163
+ * fence and is the only way the prompt can reach the model.
164
+ */
165
+ async tryReminder(text, origin, beforeQueue, releaseGuard) {
166
+ await new Promise(resolve => setImmediate(resolve));
167
+ return this.exclusive(async () => {
168
+ const snapshot = this.session.snapshot();
169
+ if (this.stopping || this.retiring || !snapshot.alive || snapshot.readiness === 'failed')
170
+ return { state: 'unavailable', error: this.stopping ? 'role is stopping' : 'session is unavailable', admitted: false };
171
+ if (snapshot.readiness !== 'idle' || this.unsettled > 0)
172
+ return { state: 'busy' };
173
+ if (!beforeQueue())
174
+ return { state: 'unavailable', error: 'claim declined', admitted: false };
175
+ try {
176
+ const queued = await this.session.queuePrompt(text, {
177
+ interrupt: false, steer: false, origin, releaseGuard,
178
+ });
179
+ return { state: 'admitted', queued: this.track(queued) };
180
+ }
181
+ catch (error) {
182
+ return { state: 'unavailable', error: error?.message ?? String(error), admitted: false };
183
+ }
184
+ }).catch(error => {
185
+ if (error instanceof AdmissionRetiredError)
186
+ return { state: 'unavailable', error: error.message, admitted: false };
187
+ throw error;
188
+ });
189
+ }
157
190
  /** Retire only at the same admission boundary used by all prompt producers. */
158
191
  retireIfIdle(expired) {
159
192
  return this.exclusive(async () => {
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from 'node:path';
6
6
  import { boundedJson } from './conversation-normalizer.js';
7
7
  import { ConversationEventStore } from './conversation-store.js';
8
8
  import { SessionEvents } from './events.js';
9
- import { memberAuthority, memberBindings, pendingInventory, takeBatch, } from './prompt-batch.js';
9
+ import { memberAuthority, memberBindings, pendingInventory, takeBatch, refuseAtRelease, RELEASE_GUARD_REFUSED, } from './prompt-batch.js';
10
10
  import { CodexAppServerTransport } from './codex-app-server-transport.js';
11
11
  import { CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED, SessionControlError, sessionBackendCapabilities, turnResult, } from './types.js';
12
12
  const PERMISSION_TIMEOUT_MS = 10 * 60_000;
@@ -27,6 +27,7 @@ function sourceFor(origin) {
27
27
  case 'owner': return { source: 'owner_channel', persistBody: false };
28
28
  case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
29
29
  case 'scheduled-loop': return { source: 'scheduled_loop', persistBody: false };
30
+ case 'self-reminder': return { source: 'fleet_monitor', persistBody: false };
30
31
  case 'local-console':
31
32
  default: return { source: 'local_console', persistBody: true };
32
33
  }
@@ -223,6 +224,9 @@ export class CodexAppServerSession {
223
224
  };
224
225
  }
225
226
  async queuePrompt(text, options = {}) {
227
+ // A guarded prompt must reach the model only through the release fence.
228
+ if (options.releaseGuard)
229
+ options = { ...options, interrupt: false, steer: false };
226
230
  if (this.closing || !this.threadId || !this.transport.isAlive())
227
231
  throw new SessionControlError('offline', this.lastError ?? 'Codex app-server session is offline');
228
232
  let delivery;
@@ -242,7 +246,7 @@ export class CodexAppServerSession {
242
246
  const run = this.observation.checkpoint(() => {
243
247
  this.admit(promptId, text, queuedBehind, options);
244
248
  this.queueDepth++;
245
- return this.enqueue(promptId, text, options.origin);
249
+ return this.enqueue(promptId, text, options.origin, options.releaseGuard);
246
250
  });
247
251
  const completion = run.then(result => {
248
252
  this.queueDepth = Math.max(0, this.queueDepth - 1);
@@ -263,11 +267,12 @@ export class CodexAppServerSession {
263
267
  return this.sharedTurns.get(promptId);
264
268
  }
265
269
  /** Each prompt keeps its own step in the chain; the step it reaches releases a whole snapshot. */
266
- enqueue(promptId, text, origin) {
270
+ enqueue(promptId, text, origin, releaseGuard) {
267
271
  const entry = {
268
272
  promptId, text, origin, ...sourceFor(origin), admittedAt: new Date().toISOString(),
269
273
  // The one text the native session runs as an operation instead of a turn.
270
274
  command: text.trim() === '/compact',
275
+ ...(releaseGuard ? { releaseGuard } : {}),
271
276
  };
272
277
  this.pendingPrompts.push(entry);
273
278
  const run = this.promptTail.then(() => this.release(entry));
@@ -283,6 +288,16 @@ export class CodexAppServerSession {
283
288
  release(entry) {
284
289
  if (entry.released)
285
290
  return entry.released;
291
+ const refused = refuseAtRelease(this.pendingPrompts, entry, Boolean(this.threadId && this.transport.isAlive()));
292
+ if (refused) {
293
+ this.conversation.appendSafe({
294
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
295
+ promptId: entry.promptId, turnId: entry.promptId,
296
+ payload: { outcome: 'cancelled', stopReason: RELEASE_GUARD_REFUSED },
297
+ });
298
+ entry.released = Promise.resolve(refused);
299
+ return entry.released;
300
+ }
286
301
  const members = takeBatch(this.pendingPrompts, entry);
287
302
  for (const member of members)
288
303
  this.sharedTurns.set(member.promptId, members[0].promptId);
@@ -18,6 +18,8 @@ export interface PendingPrompt {
18
18
  command?: boolean;
19
19
  /** Set when a release takes this prompt; every member of that turn shares it. */
20
20
  released?: Promise<TurnResult>;
21
+ /** Last-moment validity check for an internal prompt; see `refuseAtRelease`. */
22
+ releaseGuard?: () => boolean;
21
23
  }
22
24
  /**
23
25
  * How a waiting prompt takes part in a release.
@@ -31,7 +33,7 @@ export interface PendingPrompt {
31
33
  * adapter as the entire prompt, so it is its own turn and a boundary
32
34
  * between the messages before and after it. The session decides this at
33
35
  * admission; text that merely starts with a slash is an ordinary message.
34
- * - `internal`: startup, scheduled-loop and stall-watchdog prompts. They own
36
+ * - `internal`: startup, scheduled-loop, self-reminder and stall-watchdog prompts. They own
35
37
  * their turn's lifecycle (and, for a loop, the redaction of its whole
36
38
  * transcript), run alone, and never split the messages around them.
37
39
  */
@@ -45,6 +47,20 @@ export declare function commandName(text: string): string | undefined;
45
47
  * admitted after it returns belongs to the next release.
46
48
  */
47
49
  export declare function takeBatch(pending: PendingPrompt[], head: PendingPrompt): PendingPrompt[];
50
+ /**
51
+ * The release-time fence for a guarded internal prompt. A prompt can wait in
52
+ * the queue long after it was admitted; whatever it was about may have been
53
+ * cancelled, revised or expired meanwhile, or the role may be stopping. The
54
+ * guard answers at the one moment that matters — the prompt is about to reach
55
+ * the model — and a refusal removes it unsent.
56
+ *
57
+ * The guard runs only when the session is live: an offline session fails the
58
+ * turn without handing it to the adapter, so consuming the guard there would
59
+ * record a dispatch that never happened. Returns the refused result, or
60
+ * undefined when the release should proceed.
61
+ */
62
+ export declare function refuseAtRelease(pending: PendingPrompt[], entry: PendingPrompt, online: boolean): TurnResult | undefined;
63
+ export declare const RELEASE_GUARD_REFUSED = "release-guard-refused";
48
64
  /** Exact, server-computed identity of one member's body inside a shared turn. */
49
65
  export interface MemberBinding {
50
66
  index: number;
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  * small is the body: each entry carries a short preview, not the prompt.
6
6
  */
7
7
  const INVENTORY_TEXT_CHARS = 200;
8
- const INTERNAL = new Set(['startup', 'scheduled-loop', 'stall-watchdog']);
8
+ const INTERNAL = new Set(['startup', 'scheduled-loop', 'stall-watchdog', 'self-reminder']);
9
9
  export function promptClass(prompt) {
10
10
  if (INTERNAL.has(prompt.origin?.kind ?? 'local-console'))
11
11
  return 'internal';
@@ -42,6 +42,40 @@ export function takeBatch(pending, head) {
42
42
  }
43
43
  return members;
44
44
  }
45
+ /**
46
+ * The release-time fence for a guarded internal prompt. A prompt can wait in
47
+ * the queue long after it was admitted; whatever it was about may have been
48
+ * cancelled, revised or expired meanwhile, or the role may be stopping. The
49
+ * guard answers at the one moment that matters — the prompt is about to reach
50
+ * the model — and a refusal removes it unsent.
51
+ *
52
+ * The guard runs only when the session is live: an offline session fails the
53
+ * turn without handing it to the adapter, so consuming the guard there would
54
+ * record a dispatch that never happened. Returns the refused result, or
55
+ * undefined when the release should proceed.
56
+ */
57
+ export function refuseAtRelease(pending, entry, online) {
58
+ if (!entry.releaseGuard || !online)
59
+ return undefined;
60
+ // Only an internal prompt is guaranteed to be its own release; any other
61
+ // class could be carried by another prompt's batch past this fence, so a
62
+ // guarded prompt of that kind is refused rather than sent unchecked.
63
+ let allowed = false;
64
+ if (promptClass(entry) === 'internal')
65
+ try {
66
+ allowed = entry.releaseGuard() === true;
67
+ }
68
+ catch {
69
+ allowed = false;
70
+ }
71
+ if (allowed)
72
+ return undefined;
73
+ const at = pending.indexOf(entry);
74
+ if (at >= 0)
75
+ pending.splice(at, 1);
76
+ return { accepted: false, outcome: 'cancelled', succeeded: false, detail: RELEASE_GUARD_REFUSED };
77
+ }
78
+ export const RELEASE_GUARD_REFUSED = 'release-guard-refused';
45
79
  export function memberBindings(members, sourceOf) {
46
80
  return members.map((member, at) => ({
47
81
  index: at + 1,
@@ -31,6 +31,13 @@ export type PromptOrigin = {
31
31
  kind: 'scheduled-loop';
32
32
  loop: string;
33
33
  runId: string;
34
+ }
35
+ /** A due self-reminder wake; the agent's own stored reminder, never owner input. */
36
+ | {
37
+ kind: 'self-reminder';
38
+ eventId: string;
39
+ reminderId: string;
40
+ revision: number;
34
41
  } | {
35
42
  kind: 'owner-admin-console';
36
43
  commandId: string;
@@ -181,6 +188,12 @@ export interface SubmitPromptOptions {
181
188
  actor?: {
182
189
  browserSession?: string;
183
190
  };
191
+ /**
192
+ * Checked synchronously at the turn boundary, immediately before the prompt
193
+ * would be handed to a live model. False drops the prompt unsent. Only
194
+ * honoured for internal prompts, which always run alone.
195
+ */
196
+ releaseGuard?: () => boolean;
184
197
  }
185
198
  export interface SessionSnapshot {
186
199
  backend: SessionBackendId;
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
2
2
  import { parse } from 'yaml';
3
+ import { removeReminderState } from './reminders/store.js';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { basename, join } from 'node:path';
5
6
  import { replaceFileAtomically, withFileLock, withSynchronousFileLock } from './atomic-file.js';
@@ -187,11 +188,19 @@ export function archiveTempState(role, reason, outcome, detail, now = new Date()
187
188
  return undefined;
188
189
  throw error;
189
190
  }
191
+ // Archived evidence is inert: no runner will ever load it again. Scheduling
192
+ // state is not evidence, so the retired agent's reminders (and their private
193
+ // notes) are removed rather than archived with it.
190
194
  try {
191
195
  appendTermination(paths.retiring, record);
192
196
  }
193
197
  finally {
194
- renameSync(paths.retiring, paths.target);
198
+ try {
199
+ removeReminderState(paths.retiring);
200
+ }
201
+ finally {
202
+ renameSync(paths.retiring, paths.target);
203
+ }
195
204
  }
196
205
  return paths.target;
197
206
  }
@@ -193,7 +193,7 @@ export function registerConversationStream(app, services, auth) {
193
193
  if (replaced || !nextAvailable)
194
194
  stopFollow();
195
195
  if (state.health !== 'healthy')
196
- resync(state.failure ?? 'role-observation-unavailable', false);
196
+ resync(state.failure ?? 'role-observation-unavailable', state.health !== 'unsupported');
197
197
  else if (replaced)
198
198
  resync('role-replaced');
199
199
  const shouldOpen = nextAvailable && (!available || replaced);
@@ -224,7 +224,7 @@ export function registerConversationStream(app, services, auth) {
224
224
  timer.unref();
225
225
  }
226
226
  catch {
227
- resync('role-observation-unavailable', false);
227
+ resync('role-observation-unavailable');
228
228
  close();
229
229
  }
230
230
  });
@@ -42,8 +42,10 @@ The six logical types are:
42
42
  `sessionGeneration`, `stateRevision` and `ledgerBarrier`. Do not merge it as a
43
43
  patch or use its barrier as coverage.
44
44
  - `resync`: `{reason,retry,capability}`. Clear partial assembly and resynchronize
45
- explicitly or enter supported fallback. `retry:false` means persistent
46
- unsupported/oversize observation: do not loop reconnects. `capability` is
45
+ explicitly or enter supported fallback. Temporary role read/watch failure is
46
+ retryable; a fresh observer can recover after repair. `retry:false` alone does
47
+ not establish permanent incapability on older servers. Explicit unsupported
48
+ capability or persistent oversize remains supported fallback without tight reconnects. `capability` is
47
49
  `degraded`, except `auth-expired` below.
48
50
  - `keepalive`: `{at:number}`. Transport liveness only; it cannot certify role,
49
51
  session, replay, or private-control health.
@@ -130,6 +132,18 @@ history. REST remains for initial latest history, user-requested older pages,
130
132
  explicit recovery, and older/unsupported/degraded server fallback. Healthy active
131
133
  consumers must not periodically fetch role details or history.
132
134
 
135
+ Active pane and independent voice consumers recover transient transport, temporary observer or
136
+ bootstrap failures after their bounded fast attempts: one delayed attempt after 10 seconds,
137
+ then 20 seconds, then capped 30 seconds between failed settled attempts. Fallback refresh ticks
138
+ do not start probes. Retire/abort the fallback request and timer before recovery takes cursor
139
+ ownership; fence late responses without waiting indefinitely for cancellation. Resume only
140
+ same-generation certified coverage; otherwise establish a canonical latest baseline. Ordinary
141
+ REST progress and identical unsuccessful resync never reset the fast restart budget. Complete
142
+ ready ends fallback polling and resets delayed backoff. Pane generation replacement may load a
143
+ new baseline; voice generation replacement stops that session until explicit Start, without
144
+ transferring controller/provider leases. Auth-required/expired ends automatic reads/probes.
145
+
146
+
133
147
  Complete role delivery relies on native directory notifications for fleet config,
134
148
  role manifests and session files. Qualified native host filesystems are the
135
149
  intended deployment (macOS local APFS or Linux local filesystems with functional