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:
Mikael Hugo 2026-05-07 02:59:46 +02:00
parent a367c95bff
commit 3d33d3c10c
2 changed files with 491 additions and 0 deletions

View 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,
};
}

View 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);
});
});
});