Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b665a4a
chore(codex): bootstrap PR for issue #123
github-actions[bot] Dec 24, 2025
b5e1512
Merge branch 'main' into codex/issue-123
stranske Dec 24, 2025
240ce5e
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 24, 2025
7307d4c
Trim CLI status summary details
Dec 24, 2025
f4fa571
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 24, 2025
4c8f371
Merge branch 'main' into codex/issue-123
stranske Dec 24, 2025
81ccce3
Merge branch 'main' into codex/issue-123
stranske Dec 24, 2025
0439872
Suppress keepalive instructions for CLI agents
Dec 24, 2025
dbe2ff0
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 24, 2025
7348647
Merge branch 'main' into codex/issue-123
stranske Dec 25, 2025
4f32dab
Merge branch 'main' into codex/issue-123
stranske Dec 25, 2025
b10e920
style: fix black formatting in test_keepalive_workflow.py
stranske Dec 25, 2025
cc50f75
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
ce8a5c4
feat(keepalive): emit step summary for agent runs
Dec 25, 2025
41f61e8
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
b4cf71e
test(keepalive): add per-run task delta to step summary
codex Dec 25, 2025
4ca6598
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
798f4b2
feat(pr-meta): infer agent type from labels
codex Dec 25, 2025
30345a3
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
d367b37
feat(gate-summary): skip CLI agent comments
Dec 25, 2025
cf6e4dd
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
ce35620
fix(keepalive): preserve summary when updating state
Dec 25, 2025
4016f3b
chore(codex-keepalive): apply updates (PR #124)
github-actions[bot] Dec 25, 2025
f90f90c
feat(keepalive): add automatic task checkbox reconciliation
stranske Dec 25, 2025
977b944
Merge branch 'main' into codex/issue-123
stranske Dec 25, 2025
55f05a3
feat(keepalive): allow productive work to extend past max_iterations
stranske Dec 25, 2025
18bf62a
fix(agents-verifier): use auth.json instead of fake API key
stranske Dec 25, 2025
686ffec
Update .github/scripts/keepalive_loop.js
stranske Dec 25, 2025
73367d2
Update .github/scripts/keepalive_state.js
stranske Dec 25, 2025
e288c5f
Update .github/scripts/keepalive_state.js
stranske Dec 25, 2025
5f2be33
Update .github/scripts/keepalive_loop.js
stranske Dec 25, 2025
3f4bf68
fix(keepalive): revert broken UI Codex patches that broke syntax
stranske Dec 25, 2025
ad5fb7b
Update .github/scripts/keepalive_loop.js
stranske Dec 25, 2025
a2bb23e
Update .github/workflows/agents-keepalive-loop.yml
stranske Dec 25, 2025
36e1825
fix(keepalive): restore analyzeTaskCompletion function arguments
stranske Dec 25, 2025
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
247 changes: 243 additions & 4 deletions .github/scripts/__tests__/keepalive-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const {
evaluateKeepaliveLoop,
updateKeepaliveLoopSummary,
markAgentRunning,
analyzeTaskCompletion,
autoReconcileTasks,
} = require('../keepalive_loop.js');
const { formatStateComment } = require('../keepalive_state.js');

Expand Down Expand Up @@ -188,13 +190,14 @@ test('evaluateKeepaliveLoop stops when tasks are complete', async () => {
assert.equal(result.reason, 'tasks-complete');
});

test('evaluateKeepaliveLoop stops when max iterations are reached', async () => {
test('evaluateKeepaliveLoop stops when max iterations reached AND unproductive', async () => {
const pr = {
number: 404,
head: { ref: 'feature/four', sha: 'sha-4' },
labels: [{ name: 'agent:codex' }],
body: '## Tasks\n- [ ] one\n## Acceptance Criteria\n- [ ] a\n<!-- keepalive-config: {"iteration": 5, "max_iterations": 5} -->',
};
// No previous state with file changes = unproductive
const github = buildGithubStub({
pr,
workflowRuns: [{ head_sha: 'sha-4', conclusion: 'success' }],
Expand All @@ -205,7 +208,39 @@ test('evaluateKeepaliveLoop stops when max iterations are reached', async () =>
core: buildCore(),
});
assert.equal(result.action, 'stop');
assert.equal(result.reason, 'max-iterations');
assert.equal(result.reason, 'max-iterations-unproductive');
});

test('evaluateKeepaliveLoop continues past max iterations when productive', async () => {
const pr = {
number: 405,
head: { ref: 'feature/extended', sha: 'sha-ext' },
labels: [{ name: 'agent:codex' }],
body: '## Tasks\n- [ ] one\n## Acceptance Criteria\n- [ ] a',
};
// State shows productive work (files changed, no failures)
const stateComment = formatStateComment({
trace: '',
iteration: 6,
max_iterations: 5,
last_files_changed: 3,
failure: {},
});
const comments = [
{ id: 22, body: stateComment, html_url: 'https://example.com/22' },
];
const github = buildGithubStub({
pr,
comments,
workflowRuns: [{ head_sha: 'sha-ext', conclusion: 'success' }],
});
const result = await evaluateKeepaliveLoop({
github,
context: buildContext(pr.number),
core: buildCore(),
});
assert.equal(result.action, 'run', 'Should continue running when productive');
assert.equal(result.reason, 'ready-extended', 'Should show extended mode');
});

test('evaluateKeepaliveLoop waits when gate has not succeeded', async () => {
Expand Down Expand Up @@ -289,7 +324,7 @@ test('updateKeepaliveLoopSummary increments iteration and clears failures on suc

assert.equal(github.actions.length, 1);
assert.equal(github.actions[0].type, 'update');
assert.match(github.actions[0].body, /Iteration \*\*3\/5\*\*/);
assert.match(github.actions[0].body, /Iteration 3\/5/);
assert.match(github.actions[0].body, /Iteration progress \| \[######----\] 3\/5 \|/);
assert.match(github.actions[0].body, /### Last Codex Run/);
assert.match(github.actions[0].body, /✅ Success/);
Expand Down Expand Up @@ -386,7 +421,7 @@ test('updateKeepaliveLoopSummary uses state iteration when inputs have stale val
assert.equal(github.actions[0].type, 'update');
// Should preserve iteration=2 from state, NOT use stale iteration=0 from inputs
assert.match(github.actions[0].body, /"iteration":2/);
assert.match(github.actions[0].body, /Iteration \*\*2\/5\*\*/);
assert.match(github.actions[0].body, /Iteration 2\/5/);
});

test('updateKeepaliveLoopSummary pauses after repeated failures and adds label', async () => {
Expand Down Expand Up @@ -661,3 +696,207 @@ test('markAgentRunning creates comment when none exists', async () => {
assert.ok(body.includes('Claude is actively working'), 'Should capitalize agent name');
assert.ok(body.includes('Iteration | 1 of 3'), 'Should show iteration 1 (0+1)');
});

// =====================================================
// Task Reconciliation Tests
// =====================================================

test('analyzeTaskCompletion identifies high-confidence matches', async () => {
const commits = [
{ sha: 'abc123', commit: { message: 'feat: add step summary output to keepalive loop' } },
{ sha: 'def456', commit: { message: 'test: add tests for step summary emission' } },
];
const files = [
{ filename: '.github/workflows/agents-keepalive-loop.yml' },
{ filename: '.github/scripts/keepalive_loop.js' },
];

const github = {
rest: {
repos: {
async compareCommits() {
return { data: { commits } };
},
},
pulls: {
async listFiles() {
return { data: files };
},
},
},
};

const taskText = `
- [ ] Add step summary output to agents-keepalive-loop.yml after agent run
- [ ] Include: iteration number, tasks completed, files changed, outcome
- [ ] Ensure summary is visible in workflow run UI
- [ ] Unrelated task about something else entirely
`;

const result = await analyzeTaskCompletion({
github,
context: { repo: { owner: 'test', repo: 'repo' } },
prNumber: 1,
baseSha: 'base123',
headSha: 'head456',
taskText,
core: buildCore(),
});

assert.ok(result.matches.length > 0, 'Should find at least one match');

// Should match the step summary task with high confidence
const stepSummaryMatch = result.matches.find(m =>
m.task.toLowerCase().includes('step summary')
);
assert.ok(stepSummaryMatch, 'Should match step summary task');
assert.equal(stepSummaryMatch.confidence, 'high', 'Should be high confidence');
});

test('analyzeTaskCompletion returns empty for unrelated commits', async () => {
const commits = [
{ sha: 'abc123', commit: { message: 'fix: typo in readme' } },
];
const files = [
{ filename: 'README.md' },
];

const github = {
rest: {
repos: {
async compareCommits() {
return { data: { commits } };
},
},
pulls: {
async listFiles() {
return { data: files };
},
},
},
};

const taskText = `
- [ ] Implement complex feature in keepalive workflow
- [ ] Add database migrations
`;

const result = await analyzeTaskCompletion({
github,
context: { repo: { owner: 'test', repo: 'repo' } },
prNumber: 1,
baseSha: 'base123',
headSha: 'head456',
taskText,
core: buildCore(),
});

// Should find no high-confidence matches
const highConfidence = result.matches.filter(m => m.confidence === 'high');
assert.equal(highConfidence.length, 0, 'Should not find high-confidence matches for unrelated commits');
});

test('autoReconcileTasks updates PR body for high-confidence matches', async () => {
const prBody = `## Tasks
- [ ] Add step summary output to keepalive loop
- [ ] Add tests for step summary
- [x] Already completed task
`;

const commits = [
{ sha: 'abc123', commit: { message: 'feat: add step summary output to keepalive loop' } },
];
const files = [
{ filename: '.github/scripts/keepalive_loop.js' },
];

let updatedBody = null;
const github = {
rest: {
pulls: {
async get() {
return { data: { body: prBody } };
},
async update({ body }) {
updatedBody = body;
return { data: {} };
},
async listFiles() {
return { data: files };
},
},
repos: {
async compareCommits() {
return { data: { commits } };
},
},
},
};

const result = await autoReconcileTasks({
github,
context: { repo: { owner: 'test', repo: 'repo' } },
prNumber: 1,
baseSha: 'base123',
headSha: 'head456',
core: buildCore(),
});

assert.ok(result.updated, 'Should update PR body');
assert.ok(result.tasksChecked > 0, 'Should check at least one task');

if (updatedBody) {
assert.ok(updatedBody.includes('[x] Add step summary'), 'Should check off matched task');
assert.ok(updatedBody.includes('[x] Already completed'), 'Should preserve already-checked tasks');
}
});

test('autoReconcileTasks skips when no high-confidence matches', async () => {
const prBody = `## Tasks
- [ ] Implement feature X
- [ ] Add tests for feature Y
`;

const commits = [
{ sha: 'abc123', commit: { message: 'docs: update readme' } },
];
const files = [
{ filename: 'README.md' },
];

let updateCalled = false;
const github = {
rest: {
pulls: {
async get() {
return { data: { body: prBody } };
},
async update() {
updateCalled = true;
return { data: {} };
},
async listFiles() {
return { data: files };
},
},
repos: {
async compareCommits() {
return { data: { commits } };
},
},
},
};

const result = await autoReconcileTasks({
github,
context: { repo: { owner: 'test', repo: 'repo' } },
prNumber: 1,
baseSha: 'base123',
headSha: 'head456',
core: buildCore(),
});

assert.equal(result.updated, false, 'Should not update PR body');
assert.equal(result.tasksChecked, 0, 'Should not check any tasks');
assert.equal(updateCalled, false, 'Should not call update API');
});
44 changes: 42 additions & 2 deletions .github/scripts/__tests__/keepalive-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,31 @@ const {

const buildGithubStub = ({ comments = [] } = {}) => {
const actions = [];
const commentStore = comments.map((comment) => ({ ...comment }));
let nextId = 101 + commentStore.length;
const github = {
actions,
rest: {
issues: {
listComments: async () => ({ data: comments }),
listComments: async () => ({ data: commentStore }),
getComment: async ({ comment_id: commentId }) => {
const match = commentStore.find((comment) => comment.id === commentId);
return { data: match || { id: commentId, body: '' } };
},
createComment: async ({ body }) => {
const id = nextId++;
const record = { id, body, html_url: `https://example.com/${id}` };
commentStore.push(record);
actions.push({ type: 'create', body });
return { data: { id: 101, html_url: 'https://example.com/101' } };
return { data: { id, html_url: record.html_url } };
},
updateComment: async ({ body, comment_id: commentId }) => {
const match = commentStore.find((comment) => comment.id === commentId);
if (match) {
match.body = body;
} else {
commentStore.push({ id: commentId, body, html_url: `https://example.com/${commentId}` });
}
actions.push({ type: 'update', body, commentId });
return { data: { id: commentId } };
},
Expand Down Expand Up @@ -85,6 +100,31 @@ test('createKeepaliveStateManager updates existing comment', async () => {
assert.match(github.actions[0].body, /"status":"success"/);
});

test('createKeepaliveStateManager preserves summary body when updating state', async () => {
const initialBody = [
'## Keepalive Summary',
'',
formatStateComment({ trace: 'trace-1', round: '7', pr_number: 42 }),
].join('\n');
const github = buildGithubStub({
comments: [
{ id: 77, body: initialBody, html_url: 'https://example.com/77' },
],
});
const manager = await createKeepaliveStateManager({
github,
context: { repo: { owner: 'o', repo: 'r' } },
prNumber: 42,
trace: 'trace-1',
round: '7',
});
await manager.save({ result: { status: 'success' } });
assert.equal(github.actions.length, 1);
assert.equal(github.actions[0].type, 'update');
assert.match(github.actions[0].body, /## Keepalive Summary/);
assert.match(github.actions[0].body, /"status":"success"/);
});

test('loadKeepaliveState returns stored payload when present', async () => {
const storedBody = formatStateComment({ trace: 'trace-x', head_sha: 'def', version: 'v1' });
const github = buildGithubStub({ comments: [{ id: 99, body: storedBody, html_url: 'https://example.com/99' }] });
Expand Down
Loading
Loading