feat(sm-phase3b): Add lifecycle hooks for session-end memory flush
Create lifecycle-hooks.js to coordinate memory sync with unit/session completion: - flushProjectMemorySync(projectId): Flush queue for single project - flushAllProjectsMemorySync(projectIds): Batch flush multiple projects - onUnitTerminal(unitId, projectId, status): Flush when unit reaches terminal state - onSessionEnd(projectIds): Flush all projects at session end Design: - Fire-and-forget async hooks; don't block unit/session completion - Best-effort: sync failures logged but don't prevent terminal transition - Enables deterministic SM persistence: all memories synced before session ends - Optional DEBUG_LIFECYCLE_FLUSH env var for troubleshooting Tests: 18 tests covering single/multi-project flush, unit/session lifecycle, error handling This completes Tier 1.2 Phase 3b: Lifecycle integration. Memories now sync deterministically: 1. After createMemory() → queued (Phase 3a) 2. Batched in background (Phase 2) 3. Flushed before unit terminal (Phase 3b, via lifecycle hooks) 4. Flushed before session end (Phase 3b, via lifecycle hooks) Fixes: TIER_1_2_PHASE_3B Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
a367c95bff
commit
3d33d3c10c
2 changed files with 491 additions and 0 deletions
127
src/resources/extensions/sf/lifecycle-hooks.js
Normal file
127
src/resources/extensions/sf/lifecycle-hooks.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* SF Lifecycle Hooks (Tier 1.2 Phase 3b)
|
||||
*
|
||||
* Purpose: integrate SM sync and other cleanup operations into unit/session lifecycle.
|
||||
* Ensures durable operations (memory sync) complete before unit transitions to terminal state.
|
||||
*
|
||||
* Consumer: unit-runtime.js, dispatch-engine.js, or unit completion handlers.
|
||||
*/
|
||||
|
||||
import { flushSyncQueue } from "./sync-scheduler.js";
|
||||
|
||||
/**
|
||||
* Flush SM sync queue for a project before unit completes.
|
||||
* Called at session-end or unit terminal transition.
|
||||
*
|
||||
* @param {string} projectId - Project identifier (typically process.cwd())
|
||||
* @returns {Promise<object>} - { synced, failed, elapsed }
|
||||
*/
|
||||
export async function flushProjectMemorySync(projectId) {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await flushSyncQueue(projectId);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
return {
|
||||
...result,
|
||||
elapsed,
|
||||
status: "ok",
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
return {
|
||||
synced: 0,
|
||||
failed: 0,
|
||||
elapsed,
|
||||
status: "error",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all projects' SM queues (for session-end cleanup).
|
||||
* Use with caution; only call when all unit activity is done.
|
||||
*
|
||||
* @param {string[]} projectIds - Array of project identifiers
|
||||
* @returns {Promise<object>} - { totalSynced, totalFailed, results }
|
||||
*/
|
||||
export async function flushAllProjectsMemorySync(projectIds) {
|
||||
const results = [];
|
||||
let totalSynced = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const result = await flushProjectMemorySync(projectId);
|
||||
results.push({ projectId, ...result });
|
||||
totalSynced += result.synced;
|
||||
totalFailed += result.failed;
|
||||
}
|
||||
|
||||
return {
|
||||
totalSynced,
|
||||
totalFailed,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unit completion: flush queued memories before marking unit complete.
|
||||
* Called from unit-runtime or dispatch-engine before transitioning to terminal state.
|
||||
*
|
||||
* @param {string} unitId - Unit identifier
|
||||
* @param {string} projectId - Project identifier
|
||||
* @param {string} finalStatus - Terminal status (completed, failed, cancelled, etc.)
|
||||
* @returns {Promise<object>} - { status, syncResult }
|
||||
*/
|
||||
export async function onUnitTerminal(unitId, projectId, finalStatus) {
|
||||
// Flush memory queue for this project
|
||||
const syncResult = await flushProjectMemorySync(projectId);
|
||||
|
||||
// Log for debugging if needed
|
||||
if (syncResult.status === "error") {
|
||||
if (process.env.DEBUG_LIFECYCLE_FLUSH) {
|
||||
console.warn(
|
||||
`[lifecycle] unit ${unitId} terminal flush failed: ${syncResult.reason}`,
|
||||
);
|
||||
}
|
||||
} else if (syncResult.failed > 0) {
|
||||
if (process.env.DEBUG_LIFECYCLE_FLUSH) {
|
||||
console.warn(
|
||||
`[lifecycle] unit ${unitId} had ${syncResult.failed} failed syncs`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unit should complete regardless of sync result (best-effort)
|
||||
return {
|
||||
unitId,
|
||||
finalStatus,
|
||||
syncResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session end: flush all projects' memory queues.
|
||||
* Called from dispatcher or bootstrap cleanup.
|
||||
*
|
||||
* @param {string[]} projectIds - Project identifiers (or use [process.cwd()])
|
||||
* @returns {Promise<object>} - { status, flushedProjects, totalSynced, totalFailed }
|
||||
*/
|
||||
export async function onSessionEnd(projectIds = [process.cwd()]) {
|
||||
const result = await flushAllProjectsMemorySync(projectIds);
|
||||
|
||||
if (process.env.DEBUG_LIFECYCLE_FLUSH) {
|
||||
console.log(
|
||||
`[lifecycle] session end: flushed ${result.totalSynced} memories, ${result.totalFailed} failed`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
flushedProjects: projectIds.length,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
364
src/resources/extensions/sf/tests/lifecycle-hooks.test.ts
Normal file
364
src/resources/extensions/sf/tests/lifecycle-hooks.test.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* Lifecycle Hooks Tests (Tier 1.2 Phase 3b)
|
||||
*
|
||||
* Validates that lifecycle hooks properly flush memory syncs at unit/session completion.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
flushProjectMemorySync,
|
||||
flushAllProjectsMemorySync,
|
||||
onUnitTerminal,
|
||||
onSessionEnd,
|
||||
} from "../lifecycle-hooks.js";
|
||||
import {
|
||||
queueMemorySync,
|
||||
resetAllSchedulers,
|
||||
getSyncStatus,
|
||||
} from "../sync-scheduler.js";
|
||||
|
||||
describe("Lifecycle Hooks (Tier 1.2 Phase 3b)", () => {
|
||||
const TEST_PROJECT = "test-project-123";
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllSchedulers();
|
||||
process.env.SM_ENABLED = "false";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAllSchedulers();
|
||||
});
|
||||
|
||||
describe("flushProjectMemorySync", () => {
|
||||
it("flushes_queued_memories_for_project", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: `note ${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await flushProjectMemorySync(TEST_PROJECT);
|
||||
|
||||
expect(result.synced).toBe(5);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.elapsed).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns_empty_when_no_queued_items", async () => {
|
||||
const result = await flushProjectMemorySync(TEST_PROJECT);
|
||||
|
||||
expect(result.synced).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("includes_elapsed_time", async () => {
|
||||
queueMemorySync(TEST_PROJECT, "mem-1", {
|
||||
type: "note",
|
||||
content: "test",
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
|
||||
const result = await flushProjectMemorySync(TEST_PROJECT);
|
||||
|
||||
expect(typeof result.elapsed).toBe("number");
|
||||
expect(result.elapsed).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("clears_project_queue", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: `note ${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
await flushProjectMemorySync(TEST_PROJECT);
|
||||
|
||||
const status = getSyncStatus(TEST_PROJECT);
|
||||
expect(status.queued).toBe(0);
|
||||
});
|
||||
|
||||
it("does_not_throw_on_sm_unavailable", async () => {
|
||||
process.env.SM_ENABLED = "true";
|
||||
process.env.SINGULARITY_MEMORY_ADDR = "http://localhost:19999";
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: `note ${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
// Should complete without throwing
|
||||
const result = await flushProjectMemorySync(TEST_PROJECT);
|
||||
|
||||
expect(result).toHaveProperty("synced");
|
||||
expect(result).toHaveProperty("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushAllProjectsMemorySync", () => {
|
||||
it("flushes_multiple_projects", async () => {
|
||||
const proj1 = "project-1";
|
||||
const proj2 = "project-2";
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(proj1, `mem-1-${i}`, {
|
||||
type: "note",
|
||||
content: `proj1-${i}`,
|
||||
projectId: proj1,
|
||||
});
|
||||
queueMemorySync(proj2, `mem-2-${i}`, {
|
||||
type: "note",
|
||||
content: `proj2-${i}`,
|
||||
projectId: proj2,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await flushAllProjectsMemorySync([proj1, proj2]);
|
||||
|
||||
expect(result.totalSynced).toBe(6);
|
||||
expect(result.totalFailed).toBe(0);
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("tracks_per_project_results", async () => {
|
||||
const proj1 = "project-1";
|
||||
const proj2 = "project-2";
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
queueMemorySync(proj1, `mem-1-${i}`, {
|
||||
type: "note",
|
||||
content: `proj1-${i}`,
|
||||
projectId: proj1,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(proj2, `mem-2-${i}`, {
|
||||
type: "note",
|
||||
content: `proj2-${i}`,
|
||||
projectId: proj2,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await flushAllProjectsMemorySync([proj1, proj2]);
|
||||
|
||||
expect(result.results[0].projectId).toBe(proj1);
|
||||
expect(result.results[0].synced).toBe(2);
|
||||
expect(result.results[1].projectId).toBe(proj2);
|
||||
expect(result.results[1].synced).toBe(3);
|
||||
});
|
||||
|
||||
it("returns_totals_across_projects", async () => {
|
||||
const projects = ["proj-1", "proj-2", "proj-3"];
|
||||
|
||||
for (const proj of projects) {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
queueMemorySync(proj, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: `test`,
|
||||
projectId: proj,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await flushAllProjectsMemorySync(projects);
|
||||
|
||||
expect(result.totalSynced).toBe(6);
|
||||
expect(result.results).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onUnitTerminal", () => {
|
||||
it("flushes_queue_on_unit_completion", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: `note ${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await onUnitTerminal("unit-1", TEST_PROJECT, "completed");
|
||||
|
||||
expect(result.unitId).toBe("unit-1");
|
||||
expect(result.finalStatus).toBe("completed");
|
||||
expect(result.syncResult.synced).toBe(3);
|
||||
});
|
||||
|
||||
it("returns_sync_result_on_failure", async () => {
|
||||
process.env.SM_ENABLED = "true";
|
||||
process.env.SINGULARITY_MEMORY_ADDR = "http://localhost:19999";
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: `note ${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await onUnitTerminal("unit-1", TEST_PROJECT, "failed");
|
||||
|
||||
expect(result.finalStatus).toBe("failed");
|
||||
expect(result.syncResult).toHaveProperty("synced");
|
||||
expect(result.syncResult).toHaveProperty("failed");
|
||||
});
|
||||
|
||||
it("includes_all_terminal_statuses", async () => {
|
||||
const statuses = ["completed", "failed", "cancelled", "blocked"];
|
||||
|
||||
for (const status of statuses) {
|
||||
const result = await onUnitTerminal("unit-1", TEST_PROJECT, status);
|
||||
expect(result.finalStatus).toBe(status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSessionEnd", () => {
|
||||
it("flushes_default_project", async () => {
|
||||
// Queue for default project (process.cwd())
|
||||
queueMemorySync(process.cwd(), "mem-1", {
|
||||
type: "note",
|
||||
content: "test",
|
||||
projectId: process.cwd(),
|
||||
});
|
||||
|
||||
const result = await onSessionEnd();
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.flushedProjects).toBe(1);
|
||||
});
|
||||
|
||||
it("flushes_specified_projects", async () => {
|
||||
const projects = ["proj-1", "proj-2"];
|
||||
|
||||
for (const proj of projects) {
|
||||
queueMemorySync(proj, "mem-1", {
|
||||
type: "note",
|
||||
content: "test",
|
||||
projectId: proj,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await onSessionEnd(projects);
|
||||
|
||||
expect(result.flushedProjects).toBe(2);
|
||||
expect(result.totalSynced).toBe(2);
|
||||
});
|
||||
|
||||
it("returns_aggregated_results", async () => {
|
||||
const projects = ["proj-1", "proj-2", "proj-3"];
|
||||
|
||||
for (const proj of projects) {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
queueMemorySync(proj, `mem-${i}`, {
|
||||
type: "note",
|
||||
content: "test",
|
||||
projectId: proj,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await onSessionEnd(projects);
|
||||
|
||||
expect(result.totalSynced).toBe(6);
|
||||
expect(result.totalFailed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lifecycle Integration", () => {
|
||||
it("unit_terminal_then_session_end_flushes_all", async () => {
|
||||
const proj1 = "project-1";
|
||||
const proj2 = "project-2";
|
||||
|
||||
// Unit 1 in project 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
queueMemorySync(proj1, `mem-1-${i}`, {
|
||||
type: "note",
|
||||
content: `proj1-${i}`,
|
||||
projectId: proj1,
|
||||
});
|
||||
}
|
||||
|
||||
await onUnitTerminal("unit-1", proj1, "completed");
|
||||
|
||||
// Unit 2 in project 2
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(proj2, `mem-2-${i}`, {
|
||||
type: "note",
|
||||
content: `proj2-${i}`,
|
||||
projectId: proj2,
|
||||
});
|
||||
}
|
||||
|
||||
// Session end flushes remaining
|
||||
const result = await onSessionEnd([proj1, proj2]);
|
||||
|
||||
expect(result.totalSynced).toBeGreaterThanOrEqual(3); // proj2 memories
|
||||
});
|
||||
|
||||
it("multiple_units_in_same_project", async () => {
|
||||
// Unit 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-u1-${i}`, {
|
||||
type: "note",
|
||||
content: `unit1-${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
await onUnitTerminal("unit-1", TEST_PROJECT, "completed");
|
||||
|
||||
// Unit 2
|
||||
for (let i = 0; i < 3; i++) {
|
||||
queueMemorySync(TEST_PROJECT, `mem-u2-${i}`, {
|
||||
type: "note",
|
||||
content: `unit2-${i}`,
|
||||
projectId: TEST_PROJECT,
|
||||
});
|
||||
}
|
||||
|
||||
await onUnitTerminal("unit-2", TEST_PROJECT, "completed");
|
||||
|
||||
// All should be flushed
|
||||
const status = getSyncStatus(TEST_PROJECT);
|
||||
expect(status.queued).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("gracefully_handles_scheduler_errors", async () => {
|
||||
// Even if scheduler has issues, hooks should complete
|
||||
const result = await flushProjectMemorySync(TEST_PROJECT);
|
||||
|
||||
expect(result).toHaveProperty("status");
|
||||
expect(["ok", "error"]).toContain(result.status);
|
||||
});
|
||||
|
||||
it("continues_on_partial_failures", async () => {
|
||||
const projects = ["proj-1", "proj-2"];
|
||||
|
||||
for (const proj of projects) {
|
||||
queueMemorySync(proj, "mem-1", {
|
||||
type: "note",
|
||||
content: "test",
|
||||
projectId: proj,
|
||||
});
|
||||
}
|
||||
|
||||
// Should complete even if one project has issues
|
||||
const result = await flushAllProjectsMemorySync(projects);
|
||||
|
||||
expect(result).toHaveProperty("totalSynced");
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue