Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Why

OMX lifecycle SessionStart refreshes active-session telemetry before the normal
SessionStart attention contract renders. When the refreshed lane was previously
stale or dead, the startup context could lose the stalled-lane signal that told
the agent it was resuming a risky lane.

## What Changes

Capture stalled-lane attention before routing lifecycle SessionStart through the
hook runner, then prepend a bounded startup banner to the returned context. Add a
focused lifecycle-envelope regression that proves the stale-lane signal survives
the telemetry refresh.

## Impact

Affected surface is the OMX lifecycle SessionStart route. Output is bounded to
three stalled lanes and falls back silently if attention collection is
unavailable, matching existing hook best-effort behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## ADDED Requirements

### Requirement: startup-banner-from-hook-contracts-on-stalled-lane behavior
The OMX lifecycle SessionStart route SHALL preserve stalled-lane attention
signals that exist before the SessionStart hook refreshes active-session
telemetry.

#### Scenario: Lifecycle SessionStart resumes a stale lane
- **GIVEN** the repo has a stale or dead active-session lane before lifecycle routing
- **WHEN** an OMX `session_start` envelope is routed for that repo
- **THEN** the returned SessionStart context includes a bounded stalled-lane banner
- **AND** the banner is collected before the SessionStart hook refreshes telemetry.

#### Scenario: Stalled lane attention is noisy
- **GIVEN** more stalled lanes exist than the startup banner can show
- **WHEN** lifecycle SessionStart renders the startup context
- **THEN** the banner shows at most three stalled lanes
- **AND** collapsed lanes are summarized with an `attention_inbox` follow-up hint.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`; branch=`agent/codex/startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`; scope=`packages/hooks/src/lifecycle-envelope.ts`; action=`finish verification and cleanup`.
- Copy prompt: Continue `agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47` on branch `agent/codex/startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`. Work inside the existing sandbox, review `openspec/changes/agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/startup-banner-from-hook-contracts-on-st-2026-05-15-20-47 --base dev --via-pr --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47`.
- [x] 1.2 Define normative requirements in `specs/startup-banner-from-hook-contracts-on-stalled-lane/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes.
- [x] 2.2 Add/update focused regression coverage.

## 3. Verification

- [x] 3.1 Run targeted project verification commands.
- `pnpm --filter @colony/hooks test -- lifecycle-envelope.test.ts session-start.test.ts`
- `pnpm --filter @colony/hooks typecheck`
- `pnpm --filter @colony/hooks build`
- `git diff --check`
- [x] 3.2 Run `openspec validate agent-codex-startup-banner-from-hook-contracts-on-st-2026-05-15-20-47 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
71 changes: 69 additions & 2 deletions packages/hooks/src/lifecycle-envelope.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path, { join } from 'node:path';
import { loadSettings, resolveDataDir } from '@colony/config';
import { MemoryStore, TaskThread, inferIdeFromSessionId } from '@colony/core';
import { MemoryStore, TaskThread, buildAttentionInbox, inferIdeFromSessionId } from '@colony/core';
import type { ObservationRow } from '@colony/storage';
import { extractTouchedFiles, pathExtractionWarningsForToolUse } from './handlers/post-tool-use.js';
import { runHook } from './runner.js';
Expand Down Expand Up @@ -73,6 +73,8 @@ const EVENT_TYPES = new Set<OmxLifecycleEventType>([
'finish_result',
]);

const SESSION_START_STALLED_LANE_LIMIT = 3;

export function isOmxLifecycleEnvelopeLike(value: unknown): boolean {
const root = asRecord(value);
if (!root) return false;
Expand Down Expand Up @@ -260,7 +262,11 @@ async function routeLifecycleEvent(
}

if (event.event_type === 'session_start') {
const result = await runHook('session-start', hookInputFromLifecycle(event), { store });
const stalledLaneBanner = sessionStartStalledLaneBanner(store, event);
const result = prependContext(
await runHook('session-start', hookInputFromLifecycle(event), { store }),
stalledLaneBanner,
);
bindTaskFromLifecycle(store, event);
return hookRouteResult('session-start', result);
}
Expand Down Expand Up @@ -418,6 +424,67 @@ function hookRouteResult(
};
}

function prependContext<T extends { context?: string }>(result: T, prefix: string): T {
if (!prefix) return result;
return {
...result,
context: result.context ? `${prefix}\n\n${result.context}` : prefix,
};
}

function sessionStartStalledLaneBanner(
store: MemoryStore,
event: NormalizedOmxLifecycleEvent,
): string {
try {
const inbox = buildAttentionInbox(store, {
session_id: event.session_id,
agent: event.agent,
repo_root: event.repo_root,
stalled_lane_limit: SESSION_START_STALLED_LANE_LIMIT,
});
const lanes = inbox.stalled_lanes.slice(0, SESSION_START_STALLED_LANE_LIMIT);
if (lanes.length === 0) return '';

const total = inbox.summary.stalled_lane_count;
const lines = [`Stalled lanes at SessionStart (${lanes.length} of ${total}):`];
for (const lane of lanes) {
lines.push(
` -> ${lane.owner} ${lane.activity} on ${lane.branch}: ${compactBannerText(
lane.task,
)}${bannerTiming(lane.updated_at, inbox.generated_at)}`,
);
}
const collapsed = Math.max(0, total - lanes.length);
if (collapsed > 0 || inbox.stalled_lanes_truncated) {
lines.push(` Plus ${collapsed} collapsed. Run attention_inbox to see all.`);
}
return lines.join('\n');
} catch (err) {
console.error(`[colony] sessionStartStalledLaneBanner: ${(err as Error)?.message ?? err}`);
return '';
}
}

function compactBannerText(value: string): string {
const compact = value.replace(/\s+/g, ' ').trim();
return compact.length <= 140 ? compact : `${compact.slice(0, 137)}...`;
}

function bannerTiming(updatedAt: string, now: number): string {
const updated = Date.parse(updatedAt);
if (!Number.isFinite(updated)) return '';
return ` (${formatDuration(now - updated)} old)`;
}

function formatDuration(ms: number): string {
const minutes = Math.max(0, Math.round(ms / 60_000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.round(hours / 24)}d`;
}

function bindTaskFromLifecycle(
store: MemoryStore,
event: NormalizedOmxLifecycleEvent,
Expand Down
88 changes: 87 additions & 1 deletion packages/hooks/test/lifecycle-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { defaultSettings } from '@colony/config';
Expand Down Expand Up @@ -291,6 +291,47 @@ describe('OMX lifecycle envelope', () => {
expect(handoff?.content).toContain('src/git-only.ts');
expect(handoff?.content).toContain('claimed_files=src/runtime.ts');
});

it('keeps a bounded stalled-lane banner when lifecycle SessionStart refreshes telemetry', async () => {
const repo = fakeGitRepo('repo-stalled-start', 'agent/codex/stalled-start');
const sessionFile = writeActiveSession(repo, {
sessionKey: 'codex@stalled',
branch: 'agent/codex/stalled-start',
taskName: 'stale stalled task',
latestTaskPreview: 'stale stalled task',
lastHeartbeatAt: new Date(Date.now() - 10 * 60_000).toISOString(),
state: 'working',
});

const result = await runOmxLifecycleEnvelope(
envelope({
event_id: 'evt_stalled_session_start',
event_name: 'session_start',
session_id: 'codex@stalled',
cwd: repo,
repo_root: repo,
branch: 'agent/codex/stalled-start',
}),
{ store },
);

expect(result).toMatchObject({
ok: true,
event_id: 'evt_stalled_session_start',
route: 'session-start',
});
expect(result.context).toContain('Stalled lanes at SessionStart (1 of 1):');
expect(result.context).toContain('codex/codex dead on agent/codex/stalled-start');
expect(result.context).toContain('stale stalled task');

const refreshedSession = JSON.parse(readFileSync(sessionFile, 'utf8')) as {
lastHeartbeatAt?: string;
};
expect(refreshedSession.lastHeartbeatAt).not.toBeUndefined();
expect(Date.parse(refreshedSession.lastHeartbeatAt ?? '')).toBeGreaterThan(
Date.now() - 60_000,
);
});
});

function envelope(overrides: Record<string, unknown>): Record<string, unknown> {
Expand All @@ -315,6 +356,51 @@ function fakeGitRepo(name: string, branch: string): string {
return repo;
}

function writeActiveSession(
repo: string,
record: {
sessionKey: string;
branch: string;
taskName: string;
latestTaskPreview: string;
lastHeartbeatAt: string;
state: string;
},
): string {
const sessionFile = join(
repo,
'.omx',
'state',
'active-sessions',
`${record.sessionKey.replace(/[^a-zA-Z0-9._-]+/g, '_')}.json`,
);
mkdirSync(join(repo, '.omx', 'state', 'active-sessions'), { recursive: true });
writeFileSync(
sessionFile,
`${JSON.stringify(
{
schemaVersion: 1,
repoRoot: repo,
branch: record.branch,
taskName: record.taskName,
latestTaskPreview: record.latestTaskPreview,
agentName: 'codex',
cliName: 'codex',
worktreePath: repo,
taskRoutingReason: 'test stale hook contract',
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
lastHeartbeatAt: record.lastHeartbeatAt,
state: record.state,
sessionKey: record.sessionKey,
},
null,
2,
)}\n`,
'utf8',
);
return sessionFile;
}

function parseMetadata(value: string | null | undefined): Record<string, unknown> | null {
if (!value) return null;
return JSON.parse(value) as Record<string, unknown>;
Expand Down
21 changes: 16 additions & 5 deletions packages/hooks/test/session-start.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { defaultSettings } from '@colony/config';
import { defaultSettings, type Settings } from '@colony/config';
import { type Embedder, MemoryStore, TaskThread } from '@colony/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
Expand All @@ -21,6 +21,7 @@ const THRESHOLDS = {
let dir: string;
let repo: string;
let store: MemoryStore;
let storeRevision: number;

class FakeEmbedder implements Embedder {
readonly model = 'fake-model';
Expand Down Expand Up @@ -118,6 +119,7 @@ function noSuggestionDeps(): SuggestionPrefaceDeps {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'colony-session-start-suggestions-'));
repo = join(dir, 'repo');
storeRevision = 0;
mkdirSync(repo, { recursive: true });
fakeGitCheckout(repo, 'agent/codex/sessionstart-suggest');
store = new MemoryStore({
Expand All @@ -129,6 +131,15 @@ beforeEach(() => {
});
});

function resetStoreWithSettings(settings: Settings): void {
store.close();
storeRevision += 1;
store = new MemoryStore({
dbPath: join(dir, `data-${storeRevision}.db`),
settings,
});
}

afterEach(() => {
store.close();
rmSync(dir, { recursive: true, force: true });
Expand Down Expand Up @@ -192,10 +203,10 @@ describe('SessionStart predictive suggestion preface', () => {
);

it('injects the legacy verbose contract when sessionStart.contractMode is full', async () => {
store.settings = {
resetStoreWithSettings({
...store.settings,
sessionStart: { contractMode: 'full' },
};
});
const preface = await sessionStart(
store,
{ session_id: 'S-full', ide: 'codex', cwd: repo },
Expand All @@ -210,10 +221,10 @@ describe('SessionStart predictive suggestion preface', () => {
});

it('omits the contract section when sessionStart.contractMode is none', async () => {
store.settings = {
resetStoreWithSettings({
...store.settings,
sessionStart: { contractMode: 'none' },
};
});
const preface = await sessionStart(
store,
{ session_id: 'S-none', ide: 'codex', cwd: repo },
Expand Down
Loading