Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8a5751c
feat(coding-agent): add memory pressure observability
Jul 23, 2026
dc66505
fix(coding-agent): wire memory guard policy
Jul 23, 2026
969f2d8
fix(coding-agent): honor memory domains
Jul 23, 2026
6f964cc
fix(natives): enable Windows Job Object bindings
Jul 23, 2026
6d1b624
fix(natives): query current Job Object memory
Jul 23, 2026
c735c53
test(ci): align native push plan fixture
Jul 23, 2026
884f387
merge dev into memory guard observability
Jul 23, 2026
c8ac807
fix(coding-agent): harden memory pressure sampling
Jul 23, 2026
9c8dfb1
fix(natives): use Windows Job limit constants
Jul 23, 2026
08b3f84
fix(coding-agent): refresh live memory guard cadence
Jul 23, 2026
a438d56
fix(coding-agent): preserve memory pressure domains
Jul 23, 2026
c975770
fix(coding-agent): harden pressure timing and mounts
Jul 23, 2026
717907c
style(memory-guard): apply repository formatting
Jul 23, 2026
7512f70
fix(coding-agent): fail over cgroup mount candidates
Jul 24, 2026
a80a879
fix(coding-agent): select binding cgroup pressure
Jul 24, 2026
d753fa2
fix(coding-agent): preserve cgroup pressure domains
Jul 24, 2026
de04bb0
fix(coding-agent): retain all pressure candidates
Jul 24, 2026
426ca8d
fix(coding-agent): resolve five owner blockers
Jul 25, 2026
ecff73d
Merge remote-tracking branch 'upstream/dev' into feat/memory-guard-ob…
Jul 25, 2026
75af20c
fix(coding-agent): resolve all 8 owner review requirements for memory…
Jul 25, 2026
70712c3
test(coding-agent): add synthetic Windows job memory sampling unit tests
Jul 25, 2026
f9632d9
fix(natives): restore ComputerController doc comment and add trailing…
Jul 25, 2026
6276533
ci: diagnose memory successor sync
twoimo Jul 26, 2026
21c742e
chore: merge current dev into memory successor
twoimo Jul 26, 2026
ae8840d
ci: self-repair memory successor
twoimo Jul 26, 2026
9de59bc
ci: apply memory successor repair
twoimo Jul 26, 2026
46f9e5a
fix(coding-agent): harden cgroup zero limits
twoimo Jul 26, 2026
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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,31 @@ jobs:
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*
path: packages/natives/native
merge-multiple: true
- name: Verify memory-guard native loader export
run: bun test packages/natives/test/memory-guard-native.test.ts

- name: Build release binary
env:
RELEASE_TARGETS: ${{ matrix.target_id }}
run: bun run ci:release:build-binaries
- name: Smoke memory-guard native route (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$json = & "${{ matrix.binary_path }}" internal memory-guard-native-smoke --json
$data = $json | ConvertFrom-Json
if ($data.api -ne "memory_guard_windows_job_probe_v1") {
throw "unexpected api: $($data.api)"
}
if ($data.source -ne "pi_natives") {
throw "unexpected source: $($data.source)"
}
if ($data.result.platform -ne "win32") {
throw "unexpected platform: $($data.result.platform)"
}
if (@("job_snapshot", "not_in_job", "api_error") -notcontains $data.result.kind) {
throw "unexpected result kind: $($data.result.kind)"
}
- name: Smoke release binary
if: runner.os != 'Windows'
run: |
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,10 @@ windows-sys = { version = "0.61", features = [
"Win32_Storage_ProjectedFileSystem",
"Win32_System_Com",
"Win32_System_IO",
"Win32_System_JobObjects",
"Win32_System_Ioctl",
"Win32_System_LibraryLoader",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
] }
winreg = "0.56"
Expand Down
1 change: 1 addition & 0 deletions crates/pi-natives/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod highlight;
pub mod html;
pub mod keys;
pub mod linediff;
pub mod memory;
pub mod sdk;
pub mod sixel;
pub use pi_ast::language;
Expand Down
223 changes: 223 additions & 0 deletions crates/pi-natives/src/memory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
#[cfg(target_os = "windows")]
use std::{
ffi::c_void,
mem::{MaybeUninit, size_of},
};

use napi_derive::napi;
#[cfg(target_os = "windows")]
use windows_sys::Win32::{
Foundation::GetLastError,
System::{
JobObjects::{
IsProcessInJob, JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_PROCESS_MEMORY,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOBOBJECT_LIMIT_VIOLATION_INFORMATION,
JobObjectExtendedLimitInformation, JobObjectLimitViolationInformation,
QueryInformationJobObject,
},
ProcessStatus::{K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX},
Threading::GetCurrentProcess,
},
};

#[napi(object)]
pub struct WindowsJobMemoryProbeResult {
pub kind: String,
pub platform: String,
#[napi(js_name = "isInJob")]
pub is_in_job: Option<bool>,
#[napi(js_name = "jobMemoryLimitBytes")]
pub job_memory_limit_bytes: Option<String>,
#[napi(js_name = "jobMemoryUsedBytes")]
pub job_memory_used_bytes: Option<String>,
#[napi(js_name = "peakJobMemoryUsedBytes")]
pub peak_job_memory_used_bytes: Option<String>,
#[napi(js_name = "processMemoryLimitBytes")]
pub process_memory_limit_bytes: Option<String>,
#[napi(js_name = "processPrivateUsageBytes")]
pub process_private_usage_bytes: Option<String>,
#[napi(js_name = "processWorkingSetBytes")]
pub process_working_set_bytes: Option<String>,
#[napi(js_name = "peakProcessWorkingSetBytes")]
pub peak_process_working_set_bytes: Option<String>,
pub call: Option<String>,
pub code: Option<String>,
}

impl WindowsJobMemoryProbeResult {
fn unsupported_platform() -> Self {
Self {
kind: "unsupported_platform".to_string(),
platform: current_platform_tag().to_string(),
is_in_job: None,
job_memory_limit_bytes: None,
job_memory_used_bytes: None,
peak_job_memory_used_bytes: None,
process_memory_limit_bytes: None,
process_private_usage_bytes: None,
process_working_set_bytes: None,
peak_process_working_set_bytes: None,
call: None,
code: None,
}
}

#[cfg(target_os = "windows")]
fn not_in_job() -> Self {
Self {
kind: "not_in_job".to_string(),
platform: current_platform_tag().to_string(),
is_in_job: Some(false),
job_memory_limit_bytes: None,
job_memory_used_bytes: None,
peak_job_memory_used_bytes: None,
process_memory_limit_bytes: None,
process_private_usage_bytes: None,
process_working_set_bytes: None,
peak_process_working_set_bytes: None,
call: None,
code: None,
}
}

#[cfg(target_os = "windows")]
fn api_error(call: &str, code: u32) -> Self {
Self {
kind: "api_error".to_string(),
platform: current_platform_tag().to_string(),
is_in_job: None,
job_memory_limit_bytes: None,
job_memory_used_bytes: None,
peak_job_memory_used_bytes: None,
process_memory_limit_bytes: None,
process_private_usage_bytes: None,
process_working_set_bytes: None,
peak_process_working_set_bytes: None,
call: Some(call.to_string()),
code: Some(code.to_string()),
}
}

#[cfg(target_os = "windows")]
fn snapshot(
limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
usage: JOBOBJECT_LIMIT_VIOLATION_INFORMATION,
counters: PROCESS_MEMORY_COUNTERS_EX,
) -> Self {
let limit_flags = limits.BasicLimitInformation.LimitFlags;
let has_job_limit = limit_flags & JOB_OBJECT_LIMIT_JOB_MEMORY != 0;
let has_process_limit = limit_flags & JOB_OBJECT_LIMIT_PROCESS_MEMORY != 0;
Self {
kind: "job_snapshot".to_string(),
platform: current_platform_tag().to_string(),
is_in_job: Some(true),
job_memory_limit_bytes: has_job_limit.then(|| limits.JobMemoryLimit.to_string()),
job_memory_used_bytes: Some(usage.JobMemory.to_string()),
peak_job_memory_used_bytes: Some(limits.PeakJobMemoryUsed.to_string()),
process_memory_limit_bytes: has_process_limit
.then(|| limits.ProcessMemoryLimit.to_string()),
process_private_usage_bytes: Some(counters.PrivateUsage.to_string()),
process_working_set_bytes: Some(counters.WorkingSetSize.to_string()),
peak_process_working_set_bytes: Some(counters.PeakWorkingSetSize.to_string()),
call: None,
code: None,
}
}
}

const fn current_platform_tag() -> &'static str {
#[cfg(target_os = "windows")]
{
"win32"
}
#[cfg(target_os = "macos")]
{
"darwin"
}
#[cfg(target_os = "linux")]
{
"linux"
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos"), not(target_os = "linux")))]
{
std::env::consts::OS
}
}

#[napi(js_name = "probeWindowsJobMemory")]
pub fn probe_windows_job_memory() -> WindowsJobMemoryProbeResult {
#[cfg(target_os = "windows")]
{
let current_process = unsafe { GetCurrentProcess() };
let mut in_job = 0;
if unsafe { IsProcessInJob(current_process, std::ptr::null_mut(), &mut in_job) } == 0 {
return WindowsJobMemoryProbeResult::api_error("IsProcessInJob", unsafe {
GetLastError()
});
}
if in_job == 0 {
return WindowsJobMemoryProbeResult::not_in_job();
}

let mut limits = MaybeUninit::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>::zeroed();
if unsafe {
QueryInformationJobObject(
std::ptr::null_mut(),
JobObjectExtendedLimitInformation,
limits.as_mut_ptr().cast::<c_void>(),
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
std::ptr::null_mut(),
)
} == 0
{
return WindowsJobMemoryProbeResult::api_error("QueryInformationJobObject", unsafe {
GetLastError()
});
}

let mut usage = MaybeUninit::<JOBOBJECT_LIMIT_VIOLATION_INFORMATION>::zeroed();
if unsafe {
QueryInformationJobObject(
std::ptr::null_mut(),
JobObjectLimitViolationInformation,
usage.as_mut_ptr().cast::<c_void>(),
size_of::<JOBOBJECT_LIMIT_VIOLATION_INFORMATION>() as u32,
std::ptr::null_mut(),
)
} == 0
{
return WindowsJobMemoryProbeResult::api_error(
"QueryInformationJobObject(memory usage)",
unsafe { GetLastError() },
);
}

let mut counters = MaybeUninit::<PROCESS_MEMORY_COUNTERS_EX>::zeroed();
unsafe {
(*counters.as_mut_ptr()).cb = size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32;
}
if unsafe {
K32GetProcessMemoryInfo(
current_process,
counters.as_mut_ptr().cast(),
size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
)
} == 0
{
return WindowsJobMemoryProbeResult::api_error("K32GetProcessMemoryInfo", unsafe {
GetLastError()
});
}

return WindowsJobMemoryProbeResult::snapshot(
unsafe { limits.assume_init() },
unsafe { usage.assume_init() },
unsafe { counters.assume_init() },
);
}

#[cfg(not(target_os = "windows"))]
{
WindowsJobMemoryProbeResult::unsupported_platform()
}
}
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Added cross-platform memory-pressure observability with effective host/cgroup limits, configurable GC and restart advisory thresholds, typed Linux process probes, and a Windows Job Object native probe; unsupported lifecycle actions remain advisory-only.
- Ralplan consensus planning now enforces a finite planner/revision iteration budget at the native write path (default 5, configurable via `gjc.ralplan.maxIterations`). Opening another planner/revision pass past the cap fails closed with exit code 3 and an operator-visible `PLANNING-STUCK` marker instead of silent unbounded re-review; `final`/post-interview escalation remains allowed without auto-implementation. The cap also floors against on-disk `stage-*-{planner,revision}.md` artifacts so a wiped, truncated, or malformed `index.jsonl` cannot fail open after prior openers (#3165).

### Fixed
Expand Down
45 changes: 45 additions & 0 deletions packages/coding-agent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import "@gajae-code/utils/postmortem";
import { Args, type CliConfig, Command, type CommandEntry, Flags, run } from "@gajae-code/utils/cli";
import { APP_NAME, formatBunRuntimeError, MIN_BUN_VERSION, VERSION } from "@gajae-code/utils/dirs";
import { loadNative as loadNativeBindings } from "../../natives/native/loader-state.js";
import { runFixtureReport } from "./cli/fixture-report";
import { admitManagedOwnerBeforeCli, completeManagedOwnerRecovery } from "./gjc-runtime/managed-owner-admission";
import {
Expand Down Expand Up @@ -174,6 +175,46 @@ async function runChatDaemonInternalFastPath(argv: string[]): Promise<void> {
await runChatDaemonInternal(action === "discord-internal" ? "discord" : "slack", argv.slice(2));
}

type MemoryGuardNativeSmokeLoad = () => Record<string, unknown>;
type WindowsJobMemoryProbeResult = Record<string, unknown> & { kind: string };
type MemoryGuardNativeSmokeReceipt = {
api: "memory_guard_windows_job_probe_v1";
source: "pi_natives";
result: WindowsJobMemoryProbeResult;
};

export function isMemoryGuardNativeSmokeFastPath(argv: readonly string[]): boolean {
return (
argv.length === 3 && argv[0] === "internal" && argv[1] === "memory-guard-native-smoke" && argv[2] === "--json"
);
}

function parseWindowsJobMemoryProbeResult(value: unknown): WindowsJobMemoryProbeResult {
if (!value || typeof value !== "object") {
throw new Error("memory-guard-native-smoke: native probe returned a non-object result");
}
const result = value as Record<string, unknown>;
if (typeof result.kind !== "string") {
throw new Error("memory-guard-native-smoke: native probe result is missing a string kind tag");
}
return result as WindowsJobMemoryProbeResult;
}

export function runMemoryGuardNativeSmokeFastPath(
options: { loadNative?: MemoryGuardNativeSmokeLoad; writeStdout?: (text: string) => void } = {},
): void {
const probe = (options.loadNative ?? loadNativeBindings)().probeWindowsJobMemory;
if (typeof probe !== "function") {
throw new Error("memory-guard-native-smoke: probeWindowsJobMemory export missing from native addon");
}
const receipt: MemoryGuardNativeSmokeReceipt = {
api: "memory_guard_windows_job_probe_v1",
source: "pi_natives",
result: parseWindowsJobMemoryProbeResult((probe as () => unknown)()),
};
(options.writeStdout ?? (text => process.stdout.write(text)))(`${JSON.stringify(receipt)}\n`);
}

function rootFixtureArg(argv: string[]): { present: boolean; id: string | undefined } {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
Expand Down Expand Up @@ -382,6 +423,10 @@ export async function runCli(argv: string[]): Promise<void> {
}
// Re-exec could not be spawned; fall through and run in this process.
}
if (isMemoryGuardNativeSmokeFastPath(argv)) {
runMemoryGuardNativeSmokeFastPath();
return;
}
if (isTmuxOwnerIsolationCliArgv(argv)) {
await runTmuxOwnerIsolationCliFromStdin();
return;
Expand Down
Loading