@adecore/agents 0.0.1 → 0.17.0-beta.2
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/LICENSE +110 -0
- package/README.md +48 -0
- package/dist/async.d.ts +2 -0
- package/dist/async.js +31 -0
- package/dist/chat/attachment-store.d.ts +10 -0
- package/dist/chat/attachment-store.js +93 -0
- package/dist/chat/backend.d.ts +214 -0
- package/dist/chat/backend.js +1 -0
- package/dist/chat/background-work.d.ts +8 -0
- package/dist/chat/background-work.js +33 -0
- package/dist/chat/bookmark-store.d.ts +25 -0
- package/dist/chat/bookmark-store.js +141 -0
- package/dist/chat/chat-core.d.ts +311 -0
- package/dist/chat/chat-core.js +1025 -0
- package/dist/chat/chat-log.d.ts +33 -0
- package/dist/chat/chat-log.js +163 -0
- package/dist/chat/chat-process.d.ts +46 -0
- package/dist/chat/chat-process.js +190 -0
- package/dist/chat/chat-session.d.ts +209 -0
- package/dist/chat/chat-session.js +1511 -0
- package/dist/chat/chat-store.d.ts +37 -0
- package/dist/chat/chat-store.js +202 -0
- package/dist/chat/chat-title.d.ts +10 -0
- package/dist/chat/chat-title.js +74 -0
- package/dist/chat/claude-backend.d.ts +35 -0
- package/dist/chat/claude-backend.js +214 -0
- package/dist/chat/claude-protocol.d.ts +41 -0
- package/dist/chat/claude-protocol.js +642 -0
- package/dist/chat/claude-title.d.ts +11 -0
- package/dist/chat/claude-title.js +97 -0
- package/dist/chat/claude-transcript.d.ts +28 -0
- package/dist/chat/claude-transcript.js +211 -0
- package/dist/chat/codex-backend.d.ts +51 -0
- package/dist/chat/codex-backend.js +364 -0
- package/dist/chat/codex-protocol.d.ts +56 -0
- package/dist/chat/codex-protocol.js +767 -0
- package/dist/chat/codex-thread.d.ts +32 -0
- package/dist/chat/codex-thread.js +131 -0
- package/dist/chat/codex-transport.d.ts +42 -0
- package/dist/chat/codex-transport.js +188 -0
- package/dist/chat/composer-preferences.d.ts +15 -0
- package/dist/chat/composer-preferences.js +46 -0
- package/dist/chat/context-breakdown.d.ts +2 -0
- package/dist/chat/context-breakdown.js +74 -0
- package/dist/chat/delta-coalescer.d.ts +12 -0
- package/dist/chat/delta-coalescer.js +55 -0
- package/dist/chat/errors.d.ts +4 -0
- package/dist/chat/errors.js +4 -0
- package/dist/chat/fake-claude.d.ts +3 -0
- package/dist/chat/fake-claude.js +982 -0
- package/dist/chat/fake-cli.d.ts +25 -0
- package/dist/chat/fake-cli.js +157 -0
- package/dist/chat/fake-codex.d.ts +10 -0
- package/dist/chat/fake-codex.js +502 -0
- package/dist/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/dist/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/dist/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/dist/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/dist/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/dist/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/dist/chat/input.d.ts +19 -0
- package/dist/chat/input.js +71 -0
- package/dist/chat/limit-resume.d.ts +11 -0
- package/dist/chat/limit-resume.js +68 -0
- package/dist/chat/projector.d.ts +60 -0
- package/dist/chat/projector.js +860 -0
- package/dist/chat/request-summary.d.ts +3 -0
- package/dist/chat/request-summary.js +143 -0
- package/dist/chat/subagent-projection.d.ts +8 -0
- package/dist/chat/subagent-projection.js +40 -0
- package/dist/chat/subagent-reader.d.ts +66 -0
- package/dist/chat/subagent-reader.js +396 -0
- package/dist/chat/subagent-settlement.d.ts +6 -0
- package/dist/chat/subagent-settlement.js +103 -0
- package/dist/chat/thread.d.ts +30 -0
- package/dist/chat/thread.js +147 -0
- package/dist/chat/wake-chat.d.ts +20 -0
- package/dist/chat/wake-chat.js +27 -0
- package/dist/client-sinks.d.ts +9 -0
- package/dist/client-sinks.js +34 -0
- package/dist/coded-error.d.ts +5 -0
- package/dist/coded-error.js +17 -0
- package/dist/context/argv.d.ts +11 -0
- package/dist/context/argv.js +58 -0
- package/dist/context/refusal.d.ts +9 -0
- package/dist/context/refusal.js +38 -0
- package/dist/context/verb.d.ts +85 -0
- package/dist/context/verb.js +233 -0
- package/dist/error-text.d.ts +5 -0
- package/dist/error-text.js +24 -0
- package/dist/events.d.ts +12 -0
- package/dist/events.js +1 -0
- package/dist/fs.d.ts +9 -0
- package/dist/fs.js +107 -0
- package/dist/host/agent-host.d.ts +28 -0
- package/dist/host/agent-host.js +105 -0
- package/dist/host/environment.d.ts +7 -0
- package/dist/host/environment.js +22 -0
- package/dist/host/handlers.d.ts +30 -0
- package/dist/host/handlers.js +111 -0
- package/dist/host/memory-port.d.ts +2 -0
- package/dist/host/memory-port.js +25 -0
- package/dist/host/wiring.d.ts +40 -0
- package/dist/host/wiring.js +120 -0
- package/dist/lineage.d.ts +44 -0
- package/dist/lineage.js +134 -0
- package/dist/messages/deliver-message.d.ts +21 -0
- package/dist/messages/deliver-message.js +42 -0
- package/dist/messages/deliver-notice.d.ts +25 -0
- package/dist/messages/deliver-notice.js +75 -0
- package/dist/messages/notice-notes.d.ts +15 -0
- package/dist/messages/notice-notes.js +26 -0
- package/dist/messages/notice-store.d.ts +30 -0
- package/dist/messages/notice-store.js +120 -0
- package/dist/modes.d.ts +11 -0
- package/dist/modes.js +28 -0
- package/dist/outbox/manual-clock.d.ts +10 -0
- package/dist/outbox/manual-clock.js +25 -0
- package/dist/outbox/outbox-worker.d.ts +44 -0
- package/dist/outbox/outbox-worker.js +173 -0
- package/dist/outbox/outbox.d.ts +37 -0
- package/dist/outbox/outbox.js +76 -0
- package/dist/providers/accounts/accounts.d.ts +20 -0
- package/dist/providers/accounts/accounts.js +158 -0
- package/dist/providers/accounts/launch.d.ts +17 -0
- package/dist/providers/accounts/launch.js +30 -0
- package/dist/providers/accounts/service.d.ts +87 -0
- package/dist/providers/accounts/service.js +538 -0
- package/dist/providers/accounts/shadow-home.d.ts +8 -0
- package/dist/providers/accounts/shadow-home.js +70 -0
- package/dist/providers/accounts/status.d.ts +12 -0
- package/dist/providers/accounts/status.js +85 -0
- package/dist/providers/accounts/store.d.ts +14 -0
- package/dist/providers/accounts/store.js +112 -0
- package/dist/providers/accounts/test-accounts.d.ts +12 -0
- package/dist/providers/accounts/test-accounts.js +38 -0
- package/dist/providers/accounts/variables.d.ts +27 -0
- package/dist/providers/accounts/variables.js +94 -0
- package/dist/providers/catalog.d.ts +17 -0
- package/dist/providers/catalog.js +76 -0
- package/dist/providers/claude-models.json +114 -0
- package/dist/providers/claude-provider.d.ts +4 -0
- package/dist/providers/claude-provider.js +29 -0
- package/dist/providers/claude.d.ts +15 -0
- package/dist/providers/claude.js +105 -0
- package/dist/providers/codex-models.json +135 -0
- package/dist/providers/codex-provider.d.ts +4 -0
- package/dist/providers/codex-provider.js +27 -0
- package/dist/providers/codex-rules.d.ts +9 -0
- package/dist/providers/codex-rules.js +35 -0
- package/dist/providers/codex.d.ts +14 -0
- package/dist/providers/codex.js +52 -0
- package/dist/providers/detect.d.ts +6 -0
- package/dist/providers/detect.js +30 -0
- package/dist/providers/provider.d.ts +25 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/registry.d.ts +28 -0
- package/dist/providers/registry.js +118 -0
- package/dist/record-directory.d.ts +27 -0
- package/dist/record-directory.js +114 -0
- package/dist/run-process.d.ts +13 -0
- package/dist/run-process.js +42 -0
- package/dist/serializer.d.ts +9 -0
- package/dist/serializer.js +34 -0
- package/dist/skills.d.ts +21 -0
- package/dist/skills.js +174 -0
- package/dist/tasks/background-limit.d.ts +16 -0
- package/dist/tasks/background-limit.js +55 -0
- package/dist/tasks/end-children.d.ts +34 -0
- package/dist/tasks/end-children.js +53 -0
- package/dist/tasks/give-task.d.ts +14 -0
- package/dist/tasks/give-task.js +33 -0
- package/dist/tasks/task-coordinator.d.ts +52 -0
- package/dist/tasks/task-coordinator.js +323 -0
- package/dist/tasks/task-store.d.ts +49 -0
- package/dist/tasks/task-store.js +197 -0
- package/dist/tasks/task-work.d.ts +47 -0
- package/dist/tasks/task-work.js +28 -0
- package/dist/tasks/waiting-child.d.ts +51 -0
- package/dist/tasks/waiting-child.js +143 -0
- package/dist/tasks/wake-parent.d.ts +19 -0
- package/dist/tasks/wake-parent.js +109 -0
- package/dist/tasks/wiring.d.ts +51 -0
- package/dist/tasks/wiring.js +151 -0
- package/dist/title-file.d.ts +2 -0
- package/dist/title-file.js +55 -0
- package/dist/usage/accounts.d.ts +15 -0
- package/dist/usage/accounts.js +64 -0
- package/dist/usage/aggregate.d.ts +12 -0
- package/dist/usage/aggregate.js +154 -0
- package/dist/usage/exchange.d.ts +16 -0
- package/dist/usage/exchange.js +84 -0
- package/dist/usage/index-file.d.ts +15 -0
- package/dist/usage/index-file.js +123 -0
- package/dist/usage/limits/monitor.d.ts +50 -0
- package/dist/usage/limits/monitor.js +179 -0
- package/dist/usage/limits/normalize.d.ts +21 -0
- package/dist/usage/limits/normalize.js +160 -0
- package/dist/usage/limits/probe.d.ts +9 -0
- package/dist/usage/limits/probe.js +113 -0
- package/dist/usage/prices-snapshot.json +1071 -0
- package/dist/usage/pricing.d.ts +34 -0
- package/dist/usage/pricing.js +186 -0
- package/dist/usage/projects.d.ts +17 -0
- package/dist/usage/projects.js +49 -0
- package/dist/usage/readers/claude.d.ts +3 -0
- package/dist/usage/readers/claude.js +59 -0
- package/dist/usage/readers/codex.d.ts +23 -0
- package/dist/usage/readers/codex.js +155 -0
- package/dist/usage/record.d.ts +15 -0
- package/dist/usage/record.js +39 -0
- package/dist/usage/roots.d.ts +12 -0
- package/dist/usage/roots.js +29 -0
- package/dist/usage/scanner.d.ts +30 -0
- package/dist/usage/scanner.js +234 -0
- package/dist/usage/usage-service.d.ts +35 -0
- package/dist/usage/usage-service.js +130 -0
- package/dist/watch-seam.d.ts +29 -0
- package/dist/watch-seam.js +78 -0
- package/dist/watch-test-helpers.d.ts +28 -0
- package/dist/watch-test-helpers.js +86 -0
- package/package.json +42 -3
- package/src/async.ts +40 -0
- package/src/chat/attachment-store.ts +108 -0
- package/src/chat/backend.ts +199 -0
- package/src/chat/background-work.ts +42 -0
- package/src/chat/bookmark-store.ts +166 -0
- package/src/chat/chat-core.ts +1228 -0
- package/src/chat/chat-log.ts +187 -0
- package/src/chat/chat-process.ts +237 -0
- package/src/chat/chat-session.ts +1723 -0
- package/src/chat/chat-store.ts +261 -0
- package/src/chat/chat-title.ts +90 -0
- package/src/chat/claude-backend.ts +235 -0
- package/src/chat/claude-protocol.ts +684 -0
- package/src/chat/claude-title.ts +106 -0
- package/src/chat/claude-transcript.ts +233 -0
- package/src/chat/codex-backend.ts +397 -0
- package/src/chat/codex-protocol.ts +872 -0
- package/src/chat/codex-thread.ts +162 -0
- package/src/chat/codex-transport.ts +224 -0
- package/src/chat/composer-preferences.ts +67 -0
- package/src/chat/context-breakdown.ts +80 -0
- package/src/chat/delta-coalescer.ts +62 -0
- package/src/chat/errors.ts +25 -0
- package/src/chat/fake-claude.ts +1006 -0
- package/src/chat/fake-cli.ts +199 -0
- package/src/chat/fake-codex.ts +534 -0
- package/src/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/src/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/src/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/src/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/src/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/src/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/src/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/src/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/src/chat/input.ts +89 -0
- package/src/chat/limit-resume.ts +74 -0
- package/src/chat/projector.ts +969 -0
- package/src/chat/request-summary.ts +168 -0
- package/src/chat/subagent-projection.ts +45 -0
- package/src/chat/subagent-reader.ts +469 -0
- package/src/chat/subagent-settlement.ts +111 -0
- package/src/chat/thread.ts +167 -0
- package/src/chat/wake-chat.ts +45 -0
- package/src/client-sinks.ts +39 -0
- package/src/coded-error.ts +18 -0
- package/src/context/argv.ts +61 -0
- package/src/context/refusal.ts +50 -0
- package/src/context/verb.ts +392 -0
- package/src/error-text.ts +28 -0
- package/src/events.ts +9 -0
- package/src/fs.ts +118 -0
- package/src/host/agent-host.ts +124 -0
- package/src/host/environment.ts +31 -0
- package/src/host/handlers.ts +142 -0
- package/src/host/memory-port.ts +27 -0
- package/src/host/wiring.ts +164 -0
- package/src/lineage.ts +159 -0
- package/src/messages/deliver-message.ts +61 -0
- package/src/messages/deliver-notice.ts +120 -0
- package/src/messages/notice-notes.ts +34 -0
- package/src/messages/notice-store.ts +142 -0
- package/src/modes.ts +38 -0
- package/src/outbox/manual-clock.ts +31 -0
- package/src/outbox/outbox-worker.ts +214 -0
- package/src/outbox/outbox.ts +106 -0
- package/src/providers/accounts/accounts.ts +192 -0
- package/src/providers/accounts/launch.ts +55 -0
- package/src/providers/accounts/service.ts +632 -0
- package/src/providers/accounts/shadow-home.ts +85 -0
- package/src/providers/accounts/status.ts +106 -0
- package/src/providers/accounts/store.ts +158 -0
- package/src/providers/accounts/test-accounts.ts +48 -0
- package/src/providers/accounts/variables.ts +132 -0
- package/src/providers/catalog.ts +90 -0
- package/src/providers/claude-models.json +114 -0
- package/src/providers/claude-provider.ts +32 -0
- package/src/providers/claude.ts +123 -0
- package/src/providers/codex-models.json +135 -0
- package/src/providers/codex-provider.ts +30 -0
- package/src/providers/codex-rules.ts +54 -0
- package/src/providers/codex.ts +69 -0
- package/src/providers/detect.ts +40 -0
- package/src/providers/provider.ts +46 -0
- package/src/providers/registry.ts +150 -0
- package/src/record-directory.ts +136 -0
- package/src/run-process.ts +61 -0
- package/src/serializer.ts +41 -0
- package/src/skills.ts +199 -0
- package/src/tasks/background-limit.ts +72 -0
- package/src/tasks/end-children.ts +100 -0
- package/src/tasks/give-task.ts +52 -0
- package/src/tasks/task-coordinator.ts +384 -0
- package/src/tasks/task-store.ts +236 -0
- package/src/tasks/task-work.ts +62 -0
- package/src/tasks/waiting-child.ts +203 -0
- package/src/tasks/wake-parent.ts +142 -0
- package/src/tasks/wiring.ts +231 -0
- package/src/title-file.ts +57 -0
- package/src/usage/accounts.ts +86 -0
- package/src/usage/aggregate.ts +220 -0
- package/src/usage/exchange.ts +93 -0
- package/src/usage/index-file.ts +178 -0
- package/src/usage/limits/monitor.ts +239 -0
- package/src/usage/limits/normalize.ts +188 -0
- package/src/usage/limits/probe.ts +120 -0
- package/src/usage/prices-snapshot.json +1071 -0
- package/src/usage/pricing.ts +236 -0
- package/src/usage/projects.ts +65 -0
- package/src/usage/readers/claude.ts +60 -0
- package/src/usage/readers/codex.ts +196 -0
- package/src/usage/record.ts +60 -0
- package/src/usage/roots.ts +39 -0
- package/src/usage/scanner.ts +265 -0
- package/src/usage/usage-service.ts +161 -0
- package/src/watch-seam.ts +111 -0
- package/src/watch-test-helpers.ts +102 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ChatItem } from '@adecore/agent-contracts';
|
|
2
|
+
import type { ChatCore } from '../chat/chat-core.ts';
|
|
3
|
+
import type { Notice, NoticeStore } from './notice-store.ts';
|
|
4
|
+
|
|
5
|
+
/* The words of a message, which are the host's: it names its app, its nodes and its CLI in them. */
|
|
6
|
+
export interface MessageWords {
|
|
7
|
+
/* What the receiving agent hears of one message: the id to act on, the title to read, and the message itself. */
|
|
8
|
+
heard(notice: Notice): string;
|
|
9
|
+
/* The line a person reads in the thread about one message. */
|
|
10
|
+
shown(notice: Notice): string;
|
|
11
|
+
/* What the CLI is sent in a turn a message opened, under the messages its preamble carries. */
|
|
12
|
+
prompt(count: number): string;
|
|
13
|
+
/* The label above that turn in the thread. */
|
|
14
|
+
label(notices: readonly Notice[]): string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* How a chat under a node id stands, as a message on its way to it needs to know. */
|
|
18
|
+
export interface ChatNoticeTargets {
|
|
19
|
+
/* What the host holds under this id: a chat between turns, one that is in a turn, or no chat at all. */
|
|
20
|
+
chat(id: string): Promise<'idle' | 'running' | 'none'>;
|
|
21
|
+
/* Whether the turn this node is running was opened by a message of its own, which is where waking stops. */
|
|
22
|
+
fromMessage(id: string): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Where a message for a chat went, and how many now wait for it. Only `wake` owes the chat a turn,
|
|
27
|
+
* which the host opens through a `deliver-message` entry; every other outcome waits in the queue for
|
|
28
|
+
* the chat's next turn.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChatDelivery {
|
|
31
|
+
outcome: 'wake' | 'no-chat' | 'in-turn' | 'from-message';
|
|
32
|
+
waiting: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Whether the turn a chat is running was itself opened by a message. This is the whole of the stop:
|
|
37
|
+
* two agents that read each other would otherwise wake each other for as long as they kept writing,
|
|
38
|
+
* and nothing about that started with a person. It rides on the turn, so it survives a restart.
|
|
39
|
+
* Between turns the last one counts, since work it left running (a background subagent) may still
|
|
40
|
+
* write, and a turn the CLI opened by itself goes on with the step of the turn before it.
|
|
41
|
+
*/
|
|
42
|
+
export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
|
|
43
|
+
const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
|
|
44
|
+
for (let i = from; i >= 0; i--) {
|
|
45
|
+
const turn = items[i]!;
|
|
46
|
+
if (turn.kind !== 'turn') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if ((turn.messageFrom ?? []).length > 0) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (turn.origin !== 'agent' || turn.taskIds !== undefined || turn.summaryFor !== undefined) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* The chats of a core as a message sees them. */
|
|
60
|
+
export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): ChatNoticeTargets {
|
|
61
|
+
return {
|
|
62
|
+
chat: async (id) => {
|
|
63
|
+
const session = chats.get(id);
|
|
64
|
+
if (session) {
|
|
65
|
+
return session.info.activeTurnId === null ? 'idle' : 'running';
|
|
66
|
+
}
|
|
67
|
+
// A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
|
|
68
|
+
return (await chats.hasStored(id)) ? 'idle' : 'none';
|
|
69
|
+
},
|
|
70
|
+
fromMessage: (id) => {
|
|
71
|
+
const session = chats.get(id);
|
|
72
|
+
return session !== undefined && turnFromMessage(session.thread.list(), session.info.activeTurnId);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
|
|
79
|
+
* one, the way a settled task gives one: a person watching two agents cannot tell a message from an
|
|
80
|
+
* assignment, and a message nobody starts a turn for sits there until someone happens to prompt that
|
|
81
|
+
* chat. One step deep, so the turn a message opened wakes nobody with a message of its own. A node the
|
|
82
|
+
* host runs something else in, such as a terminal, is the host's to deliver to before this.
|
|
83
|
+
*/
|
|
84
|
+
export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
|
|
85
|
+
const waiting = await store.put(notice);
|
|
86
|
+
const chat = await targets.chat(notice.targetId);
|
|
87
|
+
if (chat === 'none') {
|
|
88
|
+
return { outcome: 'no-chat', waiting };
|
|
89
|
+
}
|
|
90
|
+
if (chat === 'running') {
|
|
91
|
+
return { outcome: 'in-turn', waiting };
|
|
92
|
+
}
|
|
93
|
+
if (targets.fromMessage(notice.from)) {
|
|
94
|
+
return { outcome: 'from-message', waiting };
|
|
95
|
+
}
|
|
96
|
+
return { outcome: 'wake', waiting };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* The chat a message was left for, as the two things showing it needs: whether it is there, and a line in its thread. */
|
|
100
|
+
export interface NoticeChat {
|
|
101
|
+
/* Whether the host holds a chat under the id, running or on disk. */
|
|
102
|
+
has(id: string): Promise<boolean>;
|
|
103
|
+
/* Puts a line in that chat's thread, loading the chat when nobody has. */
|
|
104
|
+
note(id: string, text: string): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* What a chat has to show a person, in its thread, the moment a message lands and not when the model
|
|
109
|
+
* gets round to it: without this a message left for a busy node is a file on disk and nothing else.
|
|
110
|
+
* An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
|
|
111
|
+
* has all of it.
|
|
112
|
+
*/
|
|
113
|
+
export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void> {
|
|
114
|
+
if (!(await chat.has(targetId))) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
for (const notice of await store.show(targetId)) {
|
|
118
|
+
await chat.note(targetId, words.shown(notice));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PromptNotes } from '../chat/chat-session.ts';
|
|
2
|
+
import type { MessageWords } from './deliver-notice.ts';
|
|
3
|
+
import type { NoticeStore } from './notice-store.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* What another node left for a chat, taken from the queue in front of its next prompt so the model
|
|
7
|
+
* hears it once. Nothing of it is shown: a person read each message in the thread as it landed
|
|
8
|
+
* (`showNotices`), so repeating it above the turn says the same thing twice.
|
|
9
|
+
*/
|
|
10
|
+
export class NoticeNotes implements PromptNotes {
|
|
11
|
+
private readonly store: Pick<NoticeStore, 'take'>;
|
|
12
|
+
private readonly chatId: string;
|
|
13
|
+
private readonly words: Pick<MessageWords, 'heard'>;
|
|
14
|
+
|
|
15
|
+
constructor(store: Pick<NoticeStore, 'take'>, chatId: string, words: Pick<MessageWords, 'heard'>) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.chatId = chatId;
|
|
18
|
+
this.words = words;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
next(): { shown: string[]; heard: string[] } {
|
|
22
|
+
return { shown: [], heard: this.store.take(this.chatId).map((notice) => this.words.heard(notice)) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
reset(): void {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* The lines a chat that just loaded owes a person for what landed while nobody held it, marked so a
|
|
30
|
+
* reload writes none again. The model still hears the messages themselves from the queue.
|
|
31
|
+
*/
|
|
32
|
+
export async function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<string[]> {
|
|
33
|
+
return (await store.show(chatId)).map((notice) => words.shown(notice));
|
|
34
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { errorText } from '../error-text.ts';
|
|
4
|
+
import { RecordDirectory } from '../record-directory.ts';
|
|
5
|
+
|
|
6
|
+
// Per receiver. Past this the oldest goes: an agent in a loop must not fill a queue nobody reads.
|
|
7
|
+
export const MAX_NOTICES = 10;
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* A message nobody picked up in this long is dropped rather than delivered. It is news about work
|
|
11
|
+
* happening now ("the build is green"), and a model reading it six hours late would act on
|
|
12
|
+
* something that has moved on. The cap alone would not do it: one message in a queue of one waits
|
|
13
|
+
* for ever.
|
|
14
|
+
*/
|
|
15
|
+
export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const NoticeSchema = z.object({
|
|
18
|
+
projectId: z.string().min(1),
|
|
19
|
+
/* The node the message is for; also the file it waits in. */
|
|
20
|
+
targetId: z.string().min(1),
|
|
21
|
+
/* The node that sent it, by id, since that is what the receiver can act on. */
|
|
22
|
+
from: z.string().min(1),
|
|
23
|
+
fromTitle: z.string(),
|
|
24
|
+
text: z.string().min(1),
|
|
25
|
+
createdAt: z.number(),
|
|
26
|
+
/*
|
|
27
|
+
* Whether a person was shown this message already. Its own mark, beside the queue: the queue is
|
|
28
|
+
* emptied by the model reading the message, and the two readers must not take it from each
|
|
29
|
+
* other. Absent is unshown, so a message left by an older host is still shown once.
|
|
30
|
+
*/
|
|
31
|
+
shown: z.boolean().optional()
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type Notice = z.infer<typeof NoticeSchema>;
|
|
35
|
+
|
|
36
|
+
/* A queue waits in the file of the node it is for, so the first message in it names that node. */
|
|
37
|
+
const FileSchema = z.object({ notices: z.array(NoticeSchema).min(1) });
|
|
38
|
+
|
|
39
|
+
type NoticeFile = z.infer<typeof FileSchema>;
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* The messages waiting for each node, held until that node's agent has a moment to hear them, under
|
|
43
|
+
* `<dataDir>/notices`. On disk because the receiver may well be a node nobody has mounted yet, and a
|
|
44
|
+
* host that restarts must not lose what was left for it. Delivered once, whichever channel gets there
|
|
45
|
+
* first.
|
|
46
|
+
*/
|
|
47
|
+
export class NoticeStore {
|
|
48
|
+
readonly dir: string;
|
|
49
|
+
private readonly queues: RecordDirectory<NoticeFile>;
|
|
50
|
+
private readonly now: () => number;
|
|
51
|
+
private lastDrop: Promise<void> = Promise.resolve();
|
|
52
|
+
|
|
53
|
+
constructor(dataDir: string, now: () => number = Date.now) {
|
|
54
|
+
this.dir = join(dataDir, 'notices');
|
|
55
|
+
this.now = now;
|
|
56
|
+
this.queues = new RecordDirectory({
|
|
57
|
+
dir: this.dir,
|
|
58
|
+
schema: FileSchema,
|
|
59
|
+
idOf: (file) => file.notices[0]!.targetId,
|
|
60
|
+
// What went stale while the host was down is not delivered; a queue of nothing else goes.
|
|
61
|
+
keep: (file) => {
|
|
62
|
+
const fresh = file.notices.filter((notice) => this.fresh(notice));
|
|
63
|
+
return fresh[0] === undefined ? null : { notices: fresh };
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
load(): Promise<void> {
|
|
69
|
+
return this.queues.load();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Puts one message in a node's queue and answers how many now wait for it. */
|
|
73
|
+
async put(notice: Omit<Notice, 'createdAt'>): Promise<number> {
|
|
74
|
+
const queue = [...this.waiting(notice.targetId), { ...notice, createdAt: this.now() }].slice(-MAX_NOTICES);
|
|
75
|
+
await this.persist(notice.targetId, queue);
|
|
76
|
+
return queue.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* Everything waiting for a node, gone the moment it is asked for. Synchronous, because the
|
|
81
|
+
* callers are: a shell's first screen and a hook's answer are both written before anything can
|
|
82
|
+
* be awaited. The queue leaves memory first and the file follows, so two channels asking in the
|
|
83
|
+
* same tick cannot both be handed the same message.
|
|
84
|
+
*/
|
|
85
|
+
take(targetId: string): Notice[] {
|
|
86
|
+
const queue = this.waiting(targetId);
|
|
87
|
+
if (queue.length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
this.lastDrop = this.persist(targetId, []).catch((e) => console.error(`Dropping the messages of ${targetId} failed:`, errorText(e)));
|
|
91
|
+
return queue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Resolves once the file the last `take` dropped is gone; `take` cannot hand that promise back itself. */
|
|
95
|
+
settled(): Promise<void> {
|
|
96
|
+
return this.lastDrop;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* What nobody showed a person yet, marked here so it is shown once. The messages stay in the
|
|
101
|
+
* queue: this is the second reader, and only the model's copy may be taken away.
|
|
102
|
+
*/
|
|
103
|
+
async show(targetId: string): Promise<Notice[]> {
|
|
104
|
+
const queue = this.waiting(targetId);
|
|
105
|
+
const unshown = queue.filter((notice) => notice.shown !== true);
|
|
106
|
+
if (unshown.length === 0) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
await this.persist(
|
|
110
|
+
targetId,
|
|
111
|
+
queue.map((notice) => ({ ...notice, shown: true }))
|
|
112
|
+
);
|
|
113
|
+
return unshown;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* What waits for a node, without taking it; the stale ones are already gone from the answer. */
|
|
117
|
+
waiting(targetId: string): Notice[] {
|
|
118
|
+
const file = this.queues.get(targetId);
|
|
119
|
+
if (file === undefined) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
return file.notices.filter((notice) => this.fresh(notice));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Drops what a project was holding for ids it no longer has: the node was deleted before it read them. */
|
|
126
|
+
prune(projectId: string, ids: ReadonlySet<string>): Promise<void> {
|
|
127
|
+
return this.queues.prune((file) => file.notices[0]!.projectId === projectId && !ids.has(file.notices[0]!.targetId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private fresh(notice: Notice): boolean {
|
|
131
|
+
return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* The queue of one node, emptied here and on disk both; an empty one leaves no file behind. */
|
|
135
|
+
private async persist(targetId: string, queue: Notice[]): Promise<void> {
|
|
136
|
+
if (queue.length === 0) {
|
|
137
|
+
await this.queues.remove(targetId);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await this.queues.write({ notices: queue });
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/modes.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { RuntimeModeSchema, type RuntimeMode } from '@adecore/agent-contracts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { VerbRefusal } from './context/verb.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Every CLI's flags widen in this order (its permission flags and the Codex thread options), and where
|
|
7
|
+
* two modes share a flag clamping down grants the same, never more. So one order serves every CLI.
|
|
8
|
+
*/
|
|
9
|
+
export const MODE_ORDER = RuntimeModeSchema.options;
|
|
10
|
+
|
|
11
|
+
function rankOf(mode: RuntimeMode): number {
|
|
12
|
+
return MODE_ORDER.indexOf(mode);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function narrowerMode(mode: RuntimeMode, ceiling: RuntimeMode): RuntimeMode {
|
|
16
|
+
return rankOf(mode) <= rankOf(ceiling) ? mode : ceiling;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function modeLines(mine: RuntimeMode): string[] {
|
|
20
|
+
return [`mode\tyou\t${mine}`, ...MODE_ORDER.filter((mode) => rankOf(mode) <= rankOf(mine)).map((mode) => `mode\t${mode}\tallowed`)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* The mode an agent the opener opens may get at most, refusing a `--mode` above it. The ceiling is
|
|
25
|
+
* the opener's own mode, so a chain of agents can only ever narrow what it may do, never widen it.
|
|
26
|
+
*/
|
|
27
|
+
export function ceilingForOpening(opener: RuntimeMode, requested: RuntimeMode | undefined): RuntimeMode {
|
|
28
|
+
if (requested !== undefined && rankOf(requested) > rankOf(opener)) {
|
|
29
|
+
throw new VerbRefusal(
|
|
30
|
+
'mode-above-parent',
|
|
31
|
+
`You run in ${opener} and --mode ${requested} is wider; an agent you open runs in your mode or a narrower one`,
|
|
32
|
+
modeLines(opener)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return opener;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const modeFlag = z.enum(MODE_ORDER, { error: `--mode is one of ${MODE_ORDER.join(', ')}` }).optional();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { OutboxClock } from './outbox-worker.ts';
|
|
2
|
+
|
|
3
|
+
/* A clock a test moves by hand. A timer fires only inside `advance`, never on its own. */
|
|
4
|
+
export class ManualClock implements OutboxClock {
|
|
5
|
+
private time = 1_000_000;
|
|
6
|
+
private timers: Array<{ at: number; run: () => void; id: number }> = [];
|
|
7
|
+
private nextId = 1;
|
|
8
|
+
|
|
9
|
+
now(): number {
|
|
10
|
+
return this.time;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
setTimeout(run: () => void, ms: number): unknown {
|
|
14
|
+
const id = this.nextId++;
|
|
15
|
+
this.timers.push({ at: this.time + ms, run, id });
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clearTimeout(handle: unknown): void {
|
|
20
|
+
this.timers = this.timers.filter((timer) => timer.id !== handle);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
advance(ms: number): void {
|
|
24
|
+
this.time += ms;
|
|
25
|
+
const due = this.timers.filter((timer) => timer.at <= this.time);
|
|
26
|
+
this.timers = this.timers.filter((timer) => timer.at > this.time);
|
|
27
|
+
for (const timer of due) {
|
|
28
|
+
timer.run();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { errorText } from '../error-text.ts';
|
|
2
|
+
import type { OutboxEntryOf, OutboxStore, OutboxWorkShape } from './outbox.ts';
|
|
3
|
+
|
|
4
|
+
/* How long a failed entry waits before each next attempt; one more failure than there are delays parks it. */
|
|
5
|
+
export const RETRY_DELAYS_MS: readonly number[] = [1_000, 5_000, 30_000];
|
|
6
|
+
|
|
7
|
+
export interface OutboxClock {
|
|
8
|
+
now(): number;
|
|
9
|
+
setTimeout(run: () => void, ms: number): unknown;
|
|
10
|
+
clearTimeout(handle: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const systemClock: OutboxClock = {
|
|
14
|
+
now: () => Date.now(),
|
|
15
|
+
setTimeout: (run, ms) => setTimeout(run, ms),
|
|
16
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* `wait` keeps the entry without counting an attempt. The work cannot happen yet (a chat is still in
|
|
21
|
+
* its turn) and runs again once `wake` names its target, never on a clock.
|
|
22
|
+
*/
|
|
23
|
+
export type OutboxOutcome = void | 'wait';
|
|
24
|
+
|
|
25
|
+
export type OutboxHandlers<Work extends OutboxWorkShape> = {
|
|
26
|
+
[K in Work['kind']]: (entry: Extract<OutboxEntryOf<Work>, { kind: K }>) => Promise<OutboxOutcome>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface OutboxWorkerOptions<Work extends OutboxWorkShape> {
|
|
30
|
+
store: OutboxStore<Work>;
|
|
31
|
+
handlers: OutboxHandlers<Work>;
|
|
32
|
+
clock?: OutboxClock;
|
|
33
|
+
/* An entry that failed every attempt and is given up on (and logged); the work it stood for is not done. */
|
|
34
|
+
onParked?: (entry: OutboxEntryOf<Work>, error: unknown) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* Works the outbox off, oldest first and one entry at a time per target, so two pieces of work
|
|
39
|
+
* about one node never overlap while nodes beside it do not wait on each other. A handler is
|
|
40
|
+
* idempotent, since a restart between the work and the removal of its file runs it again.
|
|
41
|
+
*/
|
|
42
|
+
export class OutboxWorker<Work extends OutboxWorkShape> {
|
|
43
|
+
private readonly store: OutboxStore<Work>;
|
|
44
|
+
private readonly handlers: OutboxHandlers<Work>;
|
|
45
|
+
private readonly clock: OutboxClock;
|
|
46
|
+
private readonly onParked: (entry: OutboxEntryOf<Work>, error: unknown) => void;
|
|
47
|
+
private readonly running = new Set<string>();
|
|
48
|
+
// Entries that said `wait`, until their target is woken; in memory only, so a restart looks again.
|
|
49
|
+
private readonly waiting = new Set<string>();
|
|
50
|
+
// Bumped by every wake of a target, so a wake that lands while its entry is still deciding to wait is not lost.
|
|
51
|
+
private readonly wakes = new Map<string, number>();
|
|
52
|
+
private timer: unknown = null;
|
|
53
|
+
private started = false;
|
|
54
|
+
private waiters: Array<() => void> = [];
|
|
55
|
+
|
|
56
|
+
constructor(options: OutboxWorkerOptions<Work>) {
|
|
57
|
+
this.store = options.store;
|
|
58
|
+
this.handlers = options.handlers;
|
|
59
|
+
this.clock = options.clock ?? systemClock;
|
|
60
|
+
this.onParked = options.onParked ?? (() => undefined);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
start(): void {
|
|
64
|
+
this.started = true;
|
|
65
|
+
this.drain();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stop(): void {
|
|
69
|
+
this.started = false;
|
|
70
|
+
if (this.timer !== null) {
|
|
71
|
+
this.clock.clearTimeout(this.timer);
|
|
72
|
+
this.timer = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Owes a piece of work and starts on it; resolves once the entry is on disk, not once it is done. */
|
|
77
|
+
async enqueue(projectId: string, target: string, work: Work, notBefore?: number): Promise<void> {
|
|
78
|
+
const now = this.clock.now();
|
|
79
|
+
await this.store.put(projectId, target, work, now, notBefore ?? now);
|
|
80
|
+
this.drain();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Something about this target changed (a chat ended its turn), so whatever waited on it looks again.
|
|
85
|
+
* Deferred, so it never runs inside the broadcast of the chat that said so.
|
|
86
|
+
*/
|
|
87
|
+
wake(target: string): void {
|
|
88
|
+
this.wakes.set(target, (this.wakes.get(target) ?? 0) + 1);
|
|
89
|
+
let woke = false;
|
|
90
|
+
for (const entry of this.store.list()) {
|
|
91
|
+
if (entry.target === target && this.waiting.delete(entry.id)) {
|
|
92
|
+
woke = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (woke) {
|
|
96
|
+
queueMicrotask(() => this.drain());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Resolves once nothing is running and nothing is due; an entry waiting out a retry or a wake does not count. */
|
|
101
|
+
settled(): Promise<void> {
|
|
102
|
+
if (this.isSettled()) {
|
|
103
|
+
return Promise.resolve();
|
|
104
|
+
}
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
this.waiters.push(resolve);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private drain(): void {
|
|
111
|
+
if (!this.started) {
|
|
112
|
+
this.notifySettled();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const now = this.clock.now();
|
|
116
|
+
const busy = new Set(this.running);
|
|
117
|
+
let nextDue: number | null = null;
|
|
118
|
+
for (const entry of this.store.list()) {
|
|
119
|
+
// A waiting entry holds no lane. The resume of a chat must not queue behind a wake that waits for it.
|
|
120
|
+
if (busy.has(entry.target) || this.waiting.has(entry.id)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Nor does work due at a later time, which nothing owed before it has to wait for.
|
|
124
|
+
if (entry.attempts === 0 && entry.notBefore > now) {
|
|
125
|
+
nextDue = nextDue === null ? entry.notBefore : Math.min(nextDue, entry.notBefore);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const lanes = this.store.lanesOf(entry);
|
|
129
|
+
// Oldest first per target. A younger entry never overtakes one that waits out a retry.
|
|
130
|
+
for (const lane of lanes) {
|
|
131
|
+
busy.add(lane);
|
|
132
|
+
}
|
|
133
|
+
// The other lanes only wait for work that is running; work that is merely owed there is what ending takes away.
|
|
134
|
+
if (lanes.some((lane) => lane !== entry.target && this.running.has(lane))) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (entry.notBefore > now) {
|
|
138
|
+
nextDue = nextDue === null ? entry.notBefore : Math.min(nextDue, entry.notBefore);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
void this.run(entry);
|
|
142
|
+
}
|
|
143
|
+
this.schedule(nextDue, now);
|
|
144
|
+
this.notifySettled();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async run(entry: OutboxEntryOf<Work>): Promise<void> {
|
|
148
|
+
const lanes = this.store.lanesOf(entry);
|
|
149
|
+
for (const lane of lanes) {
|
|
150
|
+
this.running.add(lane);
|
|
151
|
+
}
|
|
152
|
+
const woken = this.wakes.get(entry.target) ?? 0;
|
|
153
|
+
try {
|
|
154
|
+
const outcome = await (this.handlers[entry.kind as Work['kind']] as (entry: OutboxEntryOf<Work>) => Promise<OutboxOutcome>)(entry);
|
|
155
|
+
if (outcome === 'wait') {
|
|
156
|
+
if ((this.wakes.get(entry.target) ?? 0) === woken && this.store.has(entry.id)) {
|
|
157
|
+
this.waiting.add(entry.id);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await this.store.remove(entry.id);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
await this.failed(entry, e).catch((error: unknown) => console.error(`The outbox could not record a failed ${entry.kind}:`, errorText(error)));
|
|
164
|
+
} finally {
|
|
165
|
+
for (const lane of lanes) {
|
|
166
|
+
this.running.delete(lane);
|
|
167
|
+
}
|
|
168
|
+
this.drain();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async failed(entry: OutboxEntryOf<Work>, error: unknown): Promise<void> {
|
|
173
|
+
const delay = RETRY_DELAYS_MS[entry.attempts];
|
|
174
|
+
if (delay === undefined) {
|
|
175
|
+
await this.store.remove(entry.id);
|
|
176
|
+
console.error(`Gave up on ${entry.kind} for ${entry.target}:`, errorText(error));
|
|
177
|
+
this.onParked(entry, error);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await this.store.update({ ...entry, attempts: entry.attempts + 1, notBefore: this.clock.now() + delay });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private schedule(nextDue: number | null, now: number): void {
|
|
184
|
+
if (this.timer !== null) {
|
|
185
|
+
this.clock.clearTimeout(this.timer);
|
|
186
|
+
this.timer = null;
|
|
187
|
+
}
|
|
188
|
+
if (nextDue !== null) {
|
|
189
|
+
this.timer = this.clock.setTimeout(
|
|
190
|
+
() => {
|
|
191
|
+
this.timer = null;
|
|
192
|
+
this.drain();
|
|
193
|
+
},
|
|
194
|
+
Math.max(0, nextDue - now)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private isSettled(): boolean {
|
|
200
|
+
const now = this.clock.now();
|
|
201
|
+
return this.running.size === 0 && (!this.started || !this.store.list().some((entry) => entry.notBefore <= now && !this.waiting.has(entry.id)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private notifySettled(): void {
|
|
205
|
+
if (!this.isSettled()) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const waiting = this.waiters;
|
|
209
|
+
this.waiters = [];
|
|
210
|
+
for (const resolve of waiting) {
|
|
211
|
+
resolve();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { RecordDirectory } from '../record-directory.ts';
|
|
5
|
+
|
|
6
|
+
/* One kind of work a host owes: its schema's `kind` is a literal, and the kinds together a discriminated union. */
|
|
7
|
+
export interface OutboxWorkShape {
|
|
8
|
+
kind: string;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const OutboxEntryFieldsSchema = z.object({
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
projectId: z.string().min(1),
|
|
15
|
+
// The node the work is about. Entries for one target run one at a time, and it goes with the node.
|
|
16
|
+
target: z.string().min(1),
|
|
17
|
+
createdAt: z.number(),
|
|
18
|
+
attempts: z.number().int().nonnegative(),
|
|
19
|
+
notBefore: z.number()
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type OutboxEntryFields = z.infer<typeof OutboxEntryFieldsSchema>;
|
|
23
|
+
export type OutboxEntryOf<Work extends OutboxWorkShape> = Work & OutboxEntryFields;
|
|
24
|
+
|
|
25
|
+
export interface OutboxStoreOptions<Work extends OutboxWorkShape> {
|
|
26
|
+
/* The entries go in `outbox` under it. */
|
|
27
|
+
dataDir: string;
|
|
28
|
+
work: z.ZodType<Work>;
|
|
29
|
+
/* The nodes an entry is about, its target first; work on any of them waits while it runs. Only the target when absent. */
|
|
30
|
+
lanesOf?(entry: OutboxEntryOf<Work>): string[];
|
|
31
|
+
/* Work owed exactly because its target is gone, which pruning therefore keeps. */
|
|
32
|
+
outlivesTarget?(entry: OutboxEntryOf<Work>): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Work a host still owes, one file per entry under `<dataDir>/outbox`, removed once it is done. On
|
|
37
|
+
* disk because the owing outlives the process: a node written a moment before a restart still has
|
|
38
|
+
* its agent started after it.
|
|
39
|
+
*/
|
|
40
|
+
export class OutboxStore<Work extends OutboxWorkShape> {
|
|
41
|
+
readonly dir: string;
|
|
42
|
+
private readonly entries: RecordDirectory<OutboxEntryOf<Work>>;
|
|
43
|
+
private readonly lanes: (entry: OutboxEntryOf<Work>) => string[];
|
|
44
|
+
private readonly outlivesTarget: (entry: OutboxEntryOf<Work>) => boolean;
|
|
45
|
+
|
|
46
|
+
constructor(options: OutboxStoreOptions<Work>) {
|
|
47
|
+
this.dir = join(options.dataDir, 'outbox');
|
|
48
|
+
this.entries = new RecordDirectory<OutboxEntryOf<Work>>({
|
|
49
|
+
dir: this.dir,
|
|
50
|
+
schema: z.intersection(options.work, OutboxEntryFieldsSchema),
|
|
51
|
+
idOf: (entry) => entry.id
|
|
52
|
+
});
|
|
53
|
+
this.lanes = options.lanesOf ?? ((entry) => [entry.target]);
|
|
54
|
+
this.outlivesTarget = options.outlivesTarget ?? (() => false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Reads what an earlier run of the host still owed. Call before the worker starts. */
|
|
58
|
+
load(): Promise<void> {
|
|
59
|
+
return this.entries.load();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* `notBefore` later than `now` is work due at a time, which holds no lane until then. */
|
|
63
|
+
async put(projectId: string, target: string, work: Work, now: number, notBefore = now): Promise<OutboxEntryOf<Work>> {
|
|
64
|
+
const entry: OutboxEntryOf<Work> = {
|
|
65
|
+
...work,
|
|
66
|
+
id: `${work.kind}-${randomBytes(6).toString('hex')}`,
|
|
67
|
+
projectId,
|
|
68
|
+
target,
|
|
69
|
+
createdAt: now,
|
|
70
|
+
attempts: 0,
|
|
71
|
+
notBefore
|
|
72
|
+
};
|
|
73
|
+
await this.entries.write(entry);
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The same entry with what its last attempt left behind. */
|
|
78
|
+
async update(entry: OutboxEntryOf<Work>): Promise<void> {
|
|
79
|
+
if (!this.entries.has(entry.id)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await this.entries.write(entry);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
remove(id: string): Promise<void> {
|
|
86
|
+
return this.entries.remove(id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Oldest first, which is the order the work was owed in. */
|
|
90
|
+
list(): OutboxEntryOf<Work>[] {
|
|
91
|
+
return this.entries.all().sort((a, b) => a.createdAt - b.createdAt);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
has(id: string): boolean {
|
|
95
|
+
return this.entries.has(id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
lanesOf(entry: OutboxEntryOf<Work>): string[] {
|
|
99
|
+
return this.lanes(entry);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Drops what this project owed for ids it no longer has, since nothing is started for a node that was deleted. */
|
|
103
|
+
prune(projectId: string, ids: ReadonlySet<string>): Promise<void> {
|
|
104
|
+
return this.entries.prune((entry) => entry.projectId === projectId && !this.outlivesTarget(entry) && !ids.has(entry.target));
|
|
105
|
+
}
|
|
106
|
+
}
|