mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(agent-inbox): review round 1
- codeman agent post: accept text that starts with "-" (markdown from a file), --json and -h stay options. - DELETE /api/sessions/:id on a persisted-only row drops the inbox a detach kept. - inbox --wait: wait.ended is exit 3 only when the session list no longer has the session; a restart or detach ending the wait is exit 2 (wait again). - A read whose client hung up during ?wait= marks nothing seen. - InboxPostSchema bound is 4x the byte cap, so every oversize message gets the store's "post a file path" answer. - Store: restore sorts oldest first; the TTL sweep checks every message, not only the first. - Docs: admins can act as any session of the receiver's owner (now tested for admin and non-admin); a peek with ?wait= does not block; exit codes. - Tests: partial PUT /api/settings keeps persistence on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c6dfaa2cac
commit
a1a33cf050
15 files changed
+209
-31
No files matched your search
@@ -228,8 +228,12 @@ export class AgentInbox {
|
||||
if (!Array.isArray(raw)) continue;
|
||||
const queue: StoredMessage[] = [];
|
||||
let bytes = 0;
|
||||
for (const entry of raw) {
|
||||
if (!isStoredMessage(entry) || entry.createdAt <= cutoff) continue;
|
||||
// Oldest first whatever order the file has, so the caps keep the oldest mail,
|
||||
// which is the rule for posts too.
|
||||
const entries = raw
|
||||
.filter((entry): entry is StoredMessage => isStoredMessage(entry) && entry.createdAt > cutoff)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const entry of entries) {
|
||||
const size = Buffer.byteLength(entry.text, 'utf8');
|
||||
if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES) break;
|
||||
queue.push({ ...entry });
|
||||
@@ -253,8 +257,9 @@ export class AgentInbox {
|
||||
const queue = this.inboxes.get(sessionId);
|
||||
if (!queue) return [];
|
||||
const cutoff = this.now() - MESSAGE_TTL_MS;
|
||||
if (queue.length === 0 || queue[0].createdAt > cutoff) return queue;
|
||||
// Oldest first, so the expired ones are a prefix.
|
||||
// Every message, not only queue[0]: a clock stepping back can put an older
|
||||
// createdAt behind a newer one. At most 200 entries, so the scan is cheap.
|
||||
if (!queue.some((m) => m.createdAt <= cutoff)) return queue;
|
||||
const kept = queue.filter((m) => m.createdAt > cutoff);
|
||||
this.store(sessionId, kept);
|
||||
this.onChange?.();
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* the caller can see that has the same owner as the receiver (exact id, or a unique
|
||||
* prefix of at least 8). Anything else is "no session": a post is from the user, and a
|
||||
* read is a peek. What that buys: in multi-user mode a caller cannot name another
|
||||
* owner's session. In single-user mode every local process holds the same
|
||||
* credentials, so the header is a hint the client sets about itself.
|
||||
* owner's session (an admin, who can see every session, can act as any session of
|
||||
* the receiver's owner, as admins can everywhere else). In single-user mode every
|
||||
* local process holds the same credentials, so the header is a hint the client sets
|
||||
* about itself.
|
||||
*
|
||||
* - `POST /api/sessions/:id/inbox` stores a message. `from` is the resolved caller or
|
||||
* null. A full inbox is 409 CONFLICT; the oldest message is never dropped.
|
||||
@@ -135,8 +137,9 @@ export function registerInboxRoutes(app: FastifyInstance, ctx: SessionPort): voi
|
||||
}
|
||||
|
||||
// Read AFTER the wait: this is what the caller gets, so it must be current. A wait
|
||||
// that ended because the session was deleted reads an empty inbox.
|
||||
const read = agentInbox.read(id, { receiver });
|
||||
// that ended because the session was deleted reads an empty inbox. A client that
|
||||
// hung up never sees this answer, so its read must not mark anything seen.
|
||||
const read = agentInbox.read(id, { receiver: receiver && !wait?.aborted });
|
||||
return { success: true, data: { sessionId: id, receiver, ...read, ...(wait ? { wait } : {}) } };
|
||||
});
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
abortOnClientHangUp,
|
||||
} from '../route-helpers.js';
|
||||
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { agentInbox } from '../agent-inbox.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
@@ -1680,6 +1681,9 @@ export function registerSessionRoutes(
|
||||
// below re-looks-up the record by id, so the returned SessionState is unused here.
|
||||
findPersistedSessionOrFail(ctx.store, id, req);
|
||||
ctx.store.demoteOrRemoveSession(id);
|
||||
// A detached session kept its agent inbox (server.ts _doCleanupSession drops it
|
||||
// only on killMux); retiring the row is the delete, so the mail goes now.
|
||||
agentInbox.drop(id);
|
||||
// Mirrors the broadcast at the tail of the live-session cleanup path
|
||||
// (_doCleanupSession in server.ts) — without it, other open tabs keep
|
||||
// showing the retired row until their next unrelated fetch.
|
||||
|
||||
+7
-2
@@ -1847,12 +1847,17 @@ export const SessionWaitOutputQuerySchema = z.object({
|
||||
/**
|
||||
* Body for `POST /api/sessions/:id/inbox`. The byte cap (`MAX_MESSAGE_BYTES`) is the
|
||||
* store's, checked there in UTF-8 bytes; this bound is only the outer fence so a
|
||||
* multi-megabyte body never reaches it. Multi-line text is fine: it is stored and
|
||||
* multi-megabyte body never reaches it. It is 4× the byte cap in characters (a UTF-8
|
||||
* character is at most 4 bytes), so every over-size message gets the store's answer,
|
||||
* which tells the sender what to do instead. Multi-line text is fine: it is stored and
|
||||
* handed back as data, never typed.
|
||||
*/
|
||||
export const InboxPostSchema = z
|
||||
.object({
|
||||
text: z.string().min(1, 'text must not be empty').max(16_384),
|
||||
text: z
|
||||
.string()
|
||||
.min(1, 'text must not be empty')
|
||||
.max(4 * 16_384),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
+3
-2
@@ -1691,8 +1691,9 @@ export class WebServer extends EventEmitter {
|
||||
// detached a few lines above, before `session.stop()`, so on a delete the
|
||||
// session's own exit event never reaches the registry.
|
||||
// The agent inbox goes with a DELETED session only. killMux=false is shutdown or
|
||||
// detach, and a detached session comes back to the mail posted while it was away.
|
||||
// Before cancelAll, so a reader released by it reads an empty inbox.
|
||||
// detach: mail posted before that is still there when the session is re-attached
|
||||
// (while detached, the routes answer 404, so nothing new arrives). Before
|
||||
// cancelAll, so a reader released by it reads an empty inbox.
|
||||
if (killMux) agentInbox.drop(sessionId);
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
|
||||
Reference in new issue
Block a user