@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.
- package/dist/agent-ours/mcp-endpoint.d.ts +3 -0
- package/dist/agent-ours/mcp-endpoint.js +22 -16
- package/dist/agent-ours/service.d.ts +3 -0
- package/dist/agent-ours/service.js +4 -0
- package/dist/application/role-removal-service.js +8 -2
- package/dist/atomic-file.d.ts +12 -1
- package/dist/atomic-file.js +17 -10
- package/dist/briefing.js +16 -0
- package/dist/build-info.json +4 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +50 -0
- package/dist/reminders/manager.d.ts +68 -0
- package/dist/reminders/manager.js +212 -0
- package/dist/reminders/runtime.d.ts +27 -0
- package/dist/reminders/runtime.js +32 -0
- package/dist/reminders/schedule.d.ts +37 -0
- package/dist/reminders/schedule.js +104 -0
- package/dist/reminders/store.d.ts +241 -0
- package/dist/reminders/store.js +635 -0
- package/dist/reminders/tools.d.ts +20 -0
- package/dist/reminders/tools.js +108 -0
- package/dist/runner.d.ts +1 -1
- package/dist/runner.js +24 -0
- package/dist/session/acp.js +20 -3
- package/dist/session/arbiter.d.ts +23 -0
- package/dist/session/arbiter.js +33 -0
- package/dist/session/codex-app-server.js +18 -3
- package/dist/session/prompt-batch.d.ts +17 -1
- package/dist/session/prompt-batch.js +35 -1
- package/dist/session/types.d.ts +13 -0
- package/dist/temp-lifecycle.js +10 -1
- package/dist/web/conversation-stream.js +2 -2
- package/docs/conversation-stream.md +16 -2
- package/docs/self-reminders.md +276 -0
- package/package.json +5 -3
|
@@ -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
|
|
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
|
}
|
package/dist/session/acp.js
CHANGED
|
@@ -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;
|
package/dist/session/arbiter.js
CHANGED
|
@@ -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,
|
package/dist/session/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/temp-lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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'
|
|
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.
|
|
46
|
-
|
|
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
|