Skip to content

fix(harness): use zone-aware timestamp in memory flush header (#3088) - #3111

Open
Coder-xiaosuo wants to merge 2 commits into
agentscope-ai:mainfrom
Coder-xiaosuo:issue-fix-3088
Open

fix(harness): use zone-aware timestamp in memory flush header (#3088)#3111
Coder-xiaosuo wants to merge 2 commits into
agentscope-ai:mainfrom
Coder-xiaosuo:issue-fix-3088

Conversation

@Coder-xiaosuo

Copy link
Copy Markdown

AgentScope-Java Version

2.0.3-SNAPSHOT

Description

Background

Fixes #3088.

MemoryFlushManager appends a timestamped section to today's daily memory ledger
(memory/YYYY-MM-DD.md) on every flush. The section header was rendered with
java.time.Instant.now().toString(), and the file name date with LocalDate.now().

Root Cause

Instant is an absolute UTC point in time, so Instant.toString() always emits a
...Z suffix. As a result -Duser.timezone=Asia/Shanghai (or TZ=...) had no effect
on the header: a UTC+8 user expected 2026-09-10T16:05:32+08:00 but saw
2026-09-10T08:05:32Z. Additionally, the file name date used LocalDate.now() (the
default zone) while the header used Instant, so the two could disagree across a day
boundary.

Changes

  • MemoryFlushManager
    • Added an injectable java.time.Clock (new 4-arg constructor). Production default is
      Clock.systemDefaultZone(), so -Duser.timezone / TZ are honored; the existing
      2-arg and 3-arg constructors are preserved and delegate to it.
    • The section header is now rendered via
      ZonedDateTime.now(clock).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), which
      preserves the local UTC offset.
    • flushMemories captures a single ZonedDateTime now and passes it down to
      writeMemoryFiles, so the daily file name date and the header timestamp always come
      from the same instant (no cross-day mismatch).
    • Extracted a package-private formatTimestamp(Clock) helper so the behavior is
      directly unit-testable.
  • Tests (MemoryFlushManagerTest) — existing tests kept untouched; added:
    • formatTimestamp_rendersClockZoneOffset — parameterized over UTC / Asia/Shanghai /
      America/New_York / a cross-day case, asserting the rendered string and that
      OffsetDateTime.parse(actual).toInstant() equals the original Instant.
    • formatTimestamp_honorsSystemDefaultZone — verifies the production default
      (Clock.systemDefaultZone()) picks up the JVM default zone.
    • flushMemories_writesZoneAwareHeaderAndMatchingFileName — end-to-end: with a fixed
      clock at 2026-09-10T20:00:00Z in Asia/Shanghai, asserts the ledger file is named
      memory/2026-09-11.md and the header is ## Memory Flush — 2026-09-11T04:00:00+08:00.
  • Docsdocs/v2/en/docs/harness/memory.md and docs/v2/zh/docs/harness/memory.md
    now document that the header timestamp is a zone-aware ISO-8601 offset date-time
    (previously always ...Z) and that it still round-trips via OffsetDateTime.parse.

Note on the exact format

DateTimeFormatter.ISO_OFFSET_DATE_TIME strips trailing zeros from the fractional
second, so .309657400 is rendered as .3096574. The offset and the instant it parses
back to are unaffected. We kept this standard formatter (rather than a custom 9-digit
pattern) so the value remains a plain ISO-8601 offset date-time.

Compatibility

  • On-disk format changes from ...Z to a local offset (...+08:00 / ...-04:00).
  • Consumers parsing with OffsetDateTime.parse(actual).toInstant() (or any ISO-8601
    offset parser) are unaffected — the instant is identical, no data ambiguity.
  • Nothing else on disk changed; no public API is broken (constructors are additive).

How to test

# Multi-zone matrix (all pass)
TZ=UTC               mvn -pl agentscope-harness -am test -Dtest='MemoryFlushManager*Test'
TZ=Asia/Shanghai     mvn -pl agentscope-harness -am test -Dtest='MemoryFlushManager*Test'
TZ=America/New_York  mvn -pl agentscope-harness -am test -Dtest='MemoryFlushManager*Test'

# JVM property path
mvn -pl agentscope-harness -am test -Dtest='MemoryFlushManager*Test' -Duser.timezone=Asia/Shanghai

A/B verification matrix:

Group Zone Input Instant Rendered output Parses back to
Old UTC 2026-09-10T08:05:32.309657400Z 2026-09-10T08:05:32.309657400Z equal
New UTC same 2026-09-10T08:05:32.3096574Z equal
New Asia/Shanghai same 2026-09-10T16:05:32.3096574+08:00 equal
New America/New_York same 2026-09-10T04:05:32.3096574-04:00 equal
New Asia/Shanghai (cross-day) 2026-09-10T20:00:00Z 2026-09-11T04:00:00+08:00 equal

Fixes #3088

Checklist

Please check the following items before code is ready to be reviewed.

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

@CLAassistant

CLAassistant commented Sep 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Zone-aware flush header + injectable Clock is a correct fix for the Instant.now()/LocalDate.now() mismatch in #3088, and the multi-zone test matrix is welcome. Not approving yet for two reasons: (1) no CI signal — the only status on this head is license/cla, so the suite has not actually run; (2) the change covers only half of the ledger write path and changes the on-disk timestamp format, which deserves a closer look.

Findings

  • [Warning] MemoryFlushManager.java:118 — the new Clock overload is never used by production construction sites (HarnessAgent.java:1078, CompactionMiddleware.java:107, MemoryFlushMiddleware.java:269), so the zone is still only whatever the JVM default happens to be.
  • [Warning] MemoryFlushManager.java:136ISO_OFFSET_DATE_TIME output is not Instant.parse-compatible and has variable fractional-second width; this is a user-visible storage format change.
  • [Warning] MemoryFlushManager.java:259MemorySaveTool writes into the same memory/YYYY-MM-DD.md ledger and still has the original LocalDate.now() + Instant.now() mismatch, so one file will end up with two timestamp conventions.
  • [Warning] MemoryFlushManagerTest.java:109 — mutating the JVM-global default time zone in a unit test is a flake risk under concurrent execution; please confirm the suite is green locally.
  • [Info] MemoryFlushManagerTest.java:96 — no regression test pins the 2-arg production constructor honoring the configured zone.

Suggestions

  • Use an explicit pattern such as uuuu-MM-ddTHH:mm:ss.SSSXXX if a stable width is preferable, or document the header format change.
  • Consider a shared MemoryTimestamps-style helper so MemoryFlushManager and MemorySaveTool stay consistent.
  • @ResourceLock("user.timezone") instead of an unprotected TimeZone.setDefault.

Automated review by github-manager-bot

* {@link Clock#fixed} to pin an instant.
*/
public MemoryFlushManager(
WorkspaceManager workspaceManager, Model model, String flushPrompt, Clock clock) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The new Clock overload is not reachable from production wiring: the three construction sites still use the 2-/3-arg constructors — HarnessAgent.java:1078, CompactionMiddleware.java:107, MemoryFlushMiddleware.java:269. So in a real run the zone can only ever be the JVM default, and Clock.systemDefaultZone() is effectively fixed at construction time.

Fixing the header rendering is the right first step, but for #3088 to be configurable (rather than "whatever -Duser.timezone happens to be") it would help to plumb a ZoneId/Clock through MemoryFlushMiddleware/HarnessAgent.Builder and pass it down here. Happy to see that as a follow-up issue rather than in this PR — just want to make sure the injectable clock does not stay test-only.

}

private static String formatTimestamp(ZonedDateTime now) {
return now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Two things worth pinning down before this format ships in the on-disk ledger:

  1. The rendered value is no longer Instant.parse-compatible. Instant.parse("2026-09-10T16:05:32.3096574+08:00") throws, so any consumer (Studio/export tooling/user scripts) that parses the ## Memory Flush — header as an Instant will break on the new output. Nothing in this repo parses the header today, so the risk is downstream only, but it is a user-visible storage-format change.
  2. ISO_OFFSET_DATE_TIME drops trailing zeros in the fractional part, so the timestamp width is variable (.3096574, .3, or none at all for a whole second). Anything doing fixed-width regex/lexicographic sorting on the header will be surprised.

If stable output matters more than formatter brevity, an explicit pattern such as DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXXX") gives fixed 3-digit millis and a preserved offset. Otherwise please add a line to the changelog / migration notes calling out the header format change.

String.format("\n## Memory Flush — %s\n%s\n", formatTimestamp(now), content);

String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + today + ".md";
String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + now.toLocalDate() + ".md";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] This is only half of the daily-ledger write path. MemorySaveTool.memorySave (agentscope-harness/.../tool/MemorySaveTool.java:76-79) appends into the same memory/YYYY-MM-DD.md file and still does:

String today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
...
"\n## Memory Save — %s\n%s\n", Instant.now().toString(), ...

That path still has exactly the bug #3088 describes (file name from the default-zone date, header from a UTC Instant — they disagree across a local midnight), and after this PR a single ledger file will contain two different timestamp conventions side by side.

Consider extracting the zone-aware helper (e.g. a small MemoryTimestamps.now(clock) used by both) or filing a follow-up so the two writers stay consistent.

void formatTimestamp_honorsSystemDefaultZone() {
TimeZone original = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Mutating the JVM-global default time zone from a unit test is a flake source: TimeZone.setDefault(...) leaks to every other test in the same fork, and Surefire/JUnit concurrent execution can run them while it is set. The finally restore only helps if nothing else runs concurrently.

Suggested options, cheapest first:

  • annotate the method with @ResourceLock("user.timezone") (and @Execution(SAME_THREAD)), or
  • replace the mutation with an assertion that does not need it, e.g. assertEquals(ZoneId.systemDefault(), Clock.systemDefaultZone().getZone()) plus a fixed-instant check through ZonedDateTime.now(Clock.system(ZoneId.of("Asia/Shanghai"))).

Also note the CI signal here is still missing — the only check reported on this PR is license/cla, so the test suite has not actually run yet (workflows for first contributions normally need maintainer approval). Could you confirm mvn -pl agentscope-harness test -Dtest=MemoryFlushManager*Test is green locally under the different TZ values you listed?

"America/New_York, 2026-09-10T08:05:32.309657400Z, 2026-09-10T04:05:32.3096574-04:00",
"Asia/Shanghai, 2026-09-10T20:00:00Z, 2026-09-11T04:00:00+08:00"
})
void formatTimestamp_rendersClockZoneOffset(String zoneId, String instantStr, String expected) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Nice coverage on the helper and the end-to-end file-name/header pairing. One gap: every new assertion goes through the package-private formatTimestamp(Clock) or the explicit 4-arg constructor. There is no regression test that pins the reported symptom — the 2-arg production constructor honouring the configured zone. That is the path a future refactor is most likely to silently break. Something like "default constructor + fixed default zone ⇒ header offset matches ZoneId.systemDefault()" would close it (the honorsSystemDefaultZone case above covers the helper, not the constructor wiring).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]:HarnessAgent memory timezone error

3 participants