feat: release v1.0.0

This commit is contained in:
chrishuan
2026-06-09 15:28:05 +08:00
parent bc1575a0da
commit 9b7dbdd7b2
83 changed files with 7737 additions and 335 deletions
+262
View File
@@ -0,0 +1,262 @@
/**
* Offload Ingest Handler — fast path: COS write + task enqueue.
* Branch A: toolPairs only → write pending.jsonl, enqueue async L1
* Branch B: recentMessages only (no toolPairs) → L1.5 path
* Branch C: toolPairs + recentMessages → write pending + cache recentMessages, trigger L1 (skip L1.5)
*/
import type http from "node:http";
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { IStateBackend, TaskPayload } from "../core/state/types.js";
import type { OffloadExecutorConfig, OffloadState } from "./types.js";
import { defaultOffloadState } from "./types.js";
import { IngestRequestSchema } from "./schemas.js";
import { serializeJsonl } from "./parsers/json-utils.js";
import { buildOffloadBasePath } from "./session-utils.js";
// ─── Per-session in-process mutex for COS append serialization ───
// Layer 1 (local): ensures at most one inflight appendFile per session within
// the same Node.js process (no I/O overhead, zero-latency queue).
// Layer 2 (distributed): stateBackend.acquireLock across multiple offload server
// instances prevents concurrent AppendObject calls to the same COS key.
const pendingMutexes = new Map<string, Promise<void>>();
async function withSessionMutex<T>(sessionKey: string, fn: () => Promise<T>): Promise<T> {
const prev = pendingMutexes.get(sessionKey) ?? Promise.resolve();
let resolve!: () => void;
const next = new Promise<void>((r) => { resolve = r; });
pendingMutexes.set(sessionKey, next);
try {
await prev;
return await fn();
} finally {
resolve();
if (pendingMutexes.get(sessionKey) === next) {
pendingMutexes.delete(sessionKey);
}
}
}
/** Max attempts to acquire the distributed lock. */
const APPEND_LOCK_MAX_ATTEMPTS = 10;
/** TTL for the distributed append lock (ms). Short-lived since append is fast. */
const APPEND_LOCK_TTL_MS = 5000;
/** Delay between lock acquisition retries (ms). Uses exponential backoff: base * 2^attempt. */
const APPEND_LOCK_RETRY_BASE_MS = 50;
export interface IngestDeps {
storage: StorageAdapter;
stateBackend?: IStateBackend;
config: OffloadExecutorConfig;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
}
export async function handleIngest(
req: http.IncomingMessage,
res: http.ServerResponse,
auth: { serviceId: string },
deps: IngestDeps,
requestId: string,
parseJsonBody: <T>(req: http.IncomingMessage) => Promise<T>,
sendJson: (res: http.ServerResponse, status: number, body: unknown) => void,
successEnvelope: <T>(data: T, requestId: string) => unknown,
errorEnvelope: (code: number, message: string, requestId: string) => unknown,
): Promise<void> {
const body = await parseJsonBody(req);
const parsed = IngestRequestSchema.safeParse(body);
if (!parsed.success) {
sendJson(res, 400, errorEnvelope(400, parsed.error.message, requestId));
return;
}
const { session_id: sessionId, tool_pairs: toolPairs, prompt, recent_messages: recentMessages } = parsed.data;
const { storage, stateBackend, config } = deps;
const basePath = buildOffloadBasePath(sessionId);
// Build context string from structured prompt + recentMessages (for L1/L1.5)
let contextText: string | undefined;
if (prompt || (recentMessages && recentMessages.length > 0)) {
const parts: string[] = [];
if (recentMessages && recentMessages.length > 0) {
parts.push("历史消息,可作为参考:");
for (const m of recentMessages) {
parts.push(`[${m.role === "user" ? "User" : "Assistant"}]: ${m.content}`);
}
}
if (prompt) {
parts.push(`\n最新user message\n[User]: ${prompt}`);
}
contextText = parts.join("\n");
}
// ─── Session skip filter (shared by L1 and L1.5) ───
const INTERNAL_SESSION_RE = /memory-.*-session-\d+/;
const shouldSkipSession = INTERNAL_SESSION_RE.test(sessionId) || sessionId.includes("subagent");
// ─── Branch L1: toolPairs non-empty → write pending + trigger L1 ───
if (toolPairs.length > 0) {
if (shouldSkipSession) {
deps.logger.info(`[offload-server] ingest: L1 skipped (session=${sessionId})`);
sendJson(res, 200, successEnvelope({}, requestId));
return;
}
// Save context for L1 executor (if available)
if (contextText) {
await storage.writeFile(`${basePath}/recent-context.txt`, contextText);
}
const pendingPath = `${basePath}/pending.jsonl`;
// Map API snake_case → internal camelCase for JSONL storage
const camelPairs = toolPairs.map((tp: Record<string, unknown>) => ({
toolName: tp.tool_name,
toolCallId: tp.tool_call_id,
params: tp.params,
result: tp.result,
error: tp.error,
timestamp: tp.timestamp,
durationMs: tp.duration_ms,
}));
const lines = serializeJsonl(camelPairs);
// Two-layer serialization to prevent COS AppendPositionErr:
// Layer 1 (local mutex): queues concurrent requests within this process.
// Layer 2 (distributed lock via stateBackend): prevents races across server instances.
// If the lock cannot be acquired, return 409 so the client retries (NOT proceed without lock).
const lockAcquired = await withSessionMutex(pendingPath, async () => {
const lockKey = `offload-pending:${auth.serviceId}:${sessionId}`;
const lockOwner = requestId;
let locked = false;
if (stateBackend) {
for (let attempt = 0; attempt < APPEND_LOCK_MAX_ATTEMPTS; attempt++) {
locked = await stateBackend.acquireLock(lockKey, lockOwner, APPEND_LOCK_TTL_MS);
if (locked) break;
// Exponential backoff: 50, 100, 200, 400, 800, 1600... capped at 2000ms
const delay = Math.min(APPEND_LOCK_RETRY_BASE_MS * 2 ** attempt, 2000);
await new Promise((r) => setTimeout(r, delay));
}
if (!locked) {
deps.logger.warn(`[offload-server] ingest: append lock failed after ${APPEND_LOCK_MAX_ATTEMPTS} attempts (session=${sessionId}), returning 409`);
return false;
}
}
try {
await storage.appendFile(pendingPath, lines);
if (stateBackend) {
const raw = await storage.readFile(pendingPath);
const lineCount = raw ? raw.split("\n").filter(Boolean).length : 0;
if (lineCount >= config.forceTriggerThreshold) {
const task: TaskPayload = {
id: `offload-l1-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "offload-l1" as TaskPayload["type"],
instanceId: auth.serviceId,
sessionId,
priority: 0,
data: { sessionId, instanceId: auth.serviceId },
createdAt: Date.now(),
};
await stateBackend.enqueueTask(task);
deps.logger.info(`[offload-server] ingest: ${toolPairs.length} pairs, triggered=true (lines=${lineCount})`);
} else {
await stateBackend.setTimerIfEarlier(
auth.serviceId,
`offload-l1:${auth.serviceId}:${sessionId}`,
Date.now() + config.pendingMaxAgeSeconds * 1000,
);
deps.logger.info(`[offload-server] ingest: ${toolPairs.length} pairs, lines=${lineCount}/${config.forceTriggerThreshold}, timer set`);
}
}
return true;
} finally {
if (locked && stateBackend) {
await stateBackend.releaseLock(lockKey, lockOwner);
}
}
});
if (!lockAcquired) {
sendJson(res, 409, errorEnvelope(409, "Concurrent write conflict, please retry", requestId));
return;
}
sendJson(res, 200, successEnvelope({}, requestId));
return;
}
// ─── Branch L1.5: toolPairs empty + prompt → task judgment ───
// Skip L1.5 for inter-session messages, internal sessions, and system prompts
const shouldSkipL15 = shouldSkipSession || !!(
prompt && (
prompt.startsWith("[Inter-session message]") ||
prompt.startsWith("Pre-compaction")
)
);
if (prompt && stateBackend && !shouldSkipL15) {
const lockKey = `offload-state:${auth.serviceId}:${sessionId}`;
const lockOwner = requestId;
let locked = false;
// Use exponential backoff, same policy as L1 append lock.
for (let attempt = 0; attempt < APPEND_LOCK_MAX_ATTEMPTS; attempt++) {
locked = await stateBackend.acquireLock(lockKey, lockOwner, APPEND_LOCK_TTL_MS);
if (locked) break;
const delay = Math.min(APPEND_LOCK_RETRY_BASE_MS * 2 ** attempt, 2000);
await new Promise((r) => setTimeout(r, delay));
}
if (!locked) {
// Do NOT proceed without the lock: a concurrent writer would overwrite state.json
// and silently drop this boundary, causing the L1.5 executor to skip the task
// (findIndex returns -1 → return without error).
deps.logger.warn(`[offload-server] ingest: L1.5 state lock failed after ${APPEND_LOCK_MAX_ATTEMPTS} attempts (session=${sessionId}), returning 409`);
sendJson(res, 409, errorEnvelope(409, "Concurrent write conflict, please retry", requestId));
return;
}
try {
const state = await readState(storage, basePath);
const boundaryTimestamp = new Date().toISOString();
state.boundaries.push({
targetMmd: "_pending",
timestamp: boundaryTimestamp,
});
await writeState(storage, basePath, state);
const task: TaskPayload = {
id: `offload-l15-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "offload-l15" as TaskPayload["type"],
instanceId: auth.serviceId,
sessionId,
priority: 0,
data: { sessionId, recentMessages: contextText, boundaryTimestamp, instanceId: auth.serviceId },
createdAt: Date.now(),
};
await stateBackend.enqueueTask(task);
deps.logger.info(`[offload-server] ingest: L1.5 triggered, boundaryTs=${boundaryTimestamp}, prompt=${prompt.slice(0, 200)}`);
} finally {
await stateBackend.releaseLock(lockKey, lockOwner);
}
}
// ─── Fast return ───
sendJson(res, 200, successEnvelope({}, requestId));
}
// ─── State helpers (duplicated intentionally to keep ingest-handler self-contained) ──
async function readState(storage: StorageAdapter, basePath: string): Promise<OffloadState> {
const raw = await storage.readFile(`${basePath}/state.json`);
if (!raw) return defaultOffloadState();
try {
return { ...defaultOffloadState(), ...JSON.parse(raw) };
} catch {
return defaultOffloadState();
}
}
async function writeState(storage: StorageAdapter, basePath: string, state: OffloadState): Promise<void> {
await storage.writeFile(`${basePath}/state.json`, JSON.stringify(state));
}